Implement audio player for audio file attachments in Chat #618#1619
Implement audio player for audio file attachments in Chat #618#1619DaemonWoo wants to merge 76 commits intoteam113:mainfrom
Conversation
e5aa03c to
ab9bd10
Compare
SleepySquash
left a comment
There was a problem hiding this comment.
@DaemonWoo, I kindly ask you to look at the whole code of the task, take a look at every single file, and try to fix the issues beforehand, try to reproduces the bugs I've described and do something with the mock, please. I did some renaming, because ActiveAudioSession sounds a little bit wrong to me, I'm used to think of an "audio session" as a whole audio hardware configuration the app uses currently.
And I would like to quote one of the comments I've left, since it seems to be important, if you don't mind!
It seems like you may in a hurry in resolving the comments. Please, don't do that. It leads to sometimes minor, sometimes major difficulties that may be hard to catch.
And such things could also lead to the long reviews 🥲
| await Get.deleteAll(); | ||
| Get.put<AudioWorker>(MockAudioWorker()); | ||
|
|
There was a problem hiding this comment.
Why would you put the MockAudioWorker in both onAfterRun and onBeforeRun? This hook is invoked two times per test:
onBeforeRun
/* test 1 executes */
onAfterRun // <- First time
onBeforeRun // <- Second time
/* test 2 executes */
... etc
Shouldn't it be put only once per run? Like it does at appInitializationFn() function. Actually, I don't see a reason to use this hook at all, since appInitializationFn() already should be invoked for every app, which happens once per each test, isn't it?
There was a problem hiding this comment.
At first, it was used once in appInitializationFn(). However, i started getting an error after each scenario saying that AudioWorker is not found. I agree that there's no need to call it everywhere, i think it should only be called in OnAfterRun.
There was a problem hiding this comment.
@DaemonWoo, "not found" is a problem. It shouldn't be fixed with adding the worker every time manually. What is the reason, why does every single other mock work perfectly without such an error and only the audio worker throws that error? Such behaviour shouldn't be ignored, perhaps there's an issue somewhere deeper and more dangerous than it seems?
There was a problem hiding this comment.
This happens because of Get.deleteAll() in restart_app. Even if we call Get.put<AudioWorker>() again (similar to how we do it for GraphQL and GeoLocation providers), the error still occurs. The reason is that AudioPlayerView is not destroyed yet and continues trying to find AudioWorker.
If we make AudioWorker permanent in appInitializationFn, or reregister it in onAfterScenario, the error no longer occurs.
There was a problem hiding this comment.
This happens because of Get.deleteAll() in restart_app
That's exactly how it should be. When an app is restarted, the whole dependency tree is destroyed, isn't it?
The reason is that AudioPlayerView is not destroyed yet and continues trying to find AudioWorker.
That's a problem. Why? The view isn't displayed to anyone. It should be destroyed when an app is restarted. When you destroy the process OS runs the app in, and then launch a new process, this view is created again. And that's how this restart step should work ideally.
There was a problem hiding this comment.
@SleepySquash I think GetX dependencies are destroyed first, while the view widget is still offstage and able to rebuild. And this error occurs during that brief moment before this view is created again
There was a problem hiding this comment.
If making AudioWorker dependency permanent is bad, we can add And I return to previous page step at the end of each scenario. This way view will dispose cleanly, and only after that Get.deleteAll() would be triggered
There was a problem hiding this comment.
I think GetX dependencies are destroyed first, while the view widget is still offstage and able to rebuild
Then a view widget should get destroyed first. How can this be done? Perhaps the restart step doesn't do what it does? And it bothers me that it's the only mock that fails this way, other ones are working as they should.
I don't like an idea to fix the results of the problem and to ignore the problem itself. At least we should have an idea of what's going on in order to determine what to do.
There was a problem hiding this comment.
@SleepySquash I was wrong about restart_app step, we don't use it here. I think this error occurs when Flutter might still be in the process of unmounting or rebuilding AudioPlayerView during cleanup/navigation transitions. And when it tries to rebuild this view, GetX dependencies have already been cleaned up. If we navigate away from chat before onAfterScenario the error is gone, because AudioPlayerView has time to be fully destroyed.
| 'I see {string} audio is paused', | ||
| (name, context) async { | ||
| await context.world.appDriver.waitForAppToSettle(); | ||
|
|
||
| final AudioWorker worker = Get.find<AudioWorker>(); | ||
| final AudioId id = _findAudioId(name); | ||
|
|
||
| final bool isPaused = | ||
| (worker.activeSession.value?.item.id == id && | ||
| !worker.activeSession.value!.isPlaying) || | ||
| (worker.activeSession.value?.item.id != id); | ||
|
|
||
| expect(isPaused, true); | ||
| }, |
There was a problem hiding this comment.
When doing such steps, you may use a single step with different parameters. It reduces the code and makes it more strict.
Also you should ideally do a await context.world.appDriver.waitUntil(() async {}), because E2E tests run in real-time, unlike unit or widget tests, and a lot of delaying stuff can happen in a real-time environment. Sometimes a network connection may drop, and in order for something to happen a ~1-2 seconds delay might happen, etc. So we ideally should "wait until the status is what we expect for the status to be".
There was a problem hiding this comment.
Also, in Web version I get the following behaviour:
Screen.Recording.2026-03-27.at.13.27.25.mov
After a second drag for some reason the audio pauses.
There was a problem hiding this comment.
In HomeView, I access Playback directly via public getter and expose it to UI, while in AudioPlayerView I interact with it only through the controller, since each method needs to verify that exactly this view is active. Is it okay to have two different approaches for interacting with playback?
There was a problem hiding this comment.
@DaemonWoo, what's your opinion? Do you think it's ok or not? And why?
There was a problem hiding this comment.
@SleepySquash I think it does introduce some inconsistency, so it would be better to encapsulate playback in HomeController and expose what the view actually needs. This way, if we change the playback implementation, we won't need to modify HomeView
There was a problem hiding this comment.
@DaemonWoo, we'll still need to modify the HomeController in this case, I guess?
|
@DaemonWoo, you shouldn't push a commit after requesting a review without a notice. You can't be sure that no one is working with this branch currently after you've sent it for a review, and checking the comments and rebasing can be cumbersome things to do. Please, either notify about a commit being made beforehand, or request a review only when you are absolutely sure about the code. |
| if (isAudio) { | ||
| AudioItem? item; | ||
| Widget? progress; | ||
|
|
||
| if (e is LocalAttachment && e.file.path != null) { | ||
| item = AudioItem( | ||
| id: widget.audioId ?? AudioId('${e.id}'), | ||
| source: AudioSource.file(e.file.path!), | ||
| title: e.filename, | ||
| ); | ||
| progress = WidgetButton(onPressed: e.cancelUpload, child: leading); | ||
| } else if (e is FileAttachment) { | ||
| item = AudioItem( | ||
| id: widget.audioId ?? AudioId('${e.id}'), | ||
| source: AudioSource.url(e.original.url), | ||
| title: e.filename, | ||
| ); | ||
| } | ||
|
|
||
| if (item != null) { | ||
| return AudioPlayer( | ||
| item: item, | ||
| progress: progress, | ||
| onForbidden: e is FileAttachment | ||
| ? () async { | ||
| await widget.onForbidden?.call(); | ||
| return AudioSource.url(e.original.url); | ||
| } | ||
| : null, | ||
| ); | ||
| } |
There was a problem hiding this comment.
I don't really like the fact that all of that happens every single time the build() method is executed. This means that all of those AudioItems are being recreated every build().
| if (item != null) { | ||
| return AudioPlayer( | ||
| item: item, | ||
| progress: progress, |
There was a problem hiding this comment.
This progress is leading, not a progress, because it doesn't has to display any progress. I may do something like progress: const Icon(...) or even progress: Text(...), which breaks the naming.
| /// Indicates uploading progress. | ||
| final Widget? progress; |
There was a problem hiding this comment.
A property should have a property documentation.
| /// Callback, called when [source] fetch fails with `403` status code. | ||
| final Future<AudioSource?> Function()? onForbidden; |
| return GetBuilder( | ||
| init: AudioPlayerController( | ||
| Get.find(), | ||
| item: item, | ||
| onForbidden: onForbidden, | ||
| ), | ||
| tag: item.id.val, | ||
| builder: (AudioPlayerController c) { |
There was a problem hiding this comment.
It'd be a more appropriate for that construction to be a stateful widget rather than a view + controller, since this ain't a page. However, let it be this way.
| if (_playRequestId == playId) await stop(); | ||
| } finally { | ||
| if (_playRequestId == playId) _delegate.isLoading.value = false; |
There was a problem hiding this comment.
You sometimes use the _isStale() method, and sometimes don't. Since such misuse is already happening, then is there a reason to have this method at all?
| /// Mocked [AudioWorker] to use in the tests. | ||
| class MockAudioWorker extends AudioWorker { | ||
| MockAudioWorker() : super(delegate: DummyDelegate()); | ||
| } |
There was a problem hiding this comment.
Do we need this class at all if we could just do AudioWorker(DummyDelegate())?
| break; | ||
| } | ||
|
|
||
| position.value = position.value + const Duration(seconds: 1); |
There was a problem hiding this comment.
Can it exceed the total Duration this way?
|
|
||
| When I pause "test.mp3" audio | ||
| Then I see "test.mp3" audio is paused | ||
| And I return to previous page |
There was a problem hiding this comment.
Adding this I return to previous page still seems like a workaround of the issue instead of a solution. I can imagine this error happening currently in tests to happen in a real world scenario. For example, when application's business logic is being deconstructed due to an account switch, or due to a logout. Or even when the app is being closed. Since you first assumed that the problem is within restart_app step, which happened to be a different thing, can you please deconstruct the exception? This "not found" error should be happening due to some method being executed in the widget, I guess? Or why? Let's try to pinpoint the problem, and only afterwards decide as to how it should be fixed, please
| final cachedDuration = _durationCache[item.id]; | ||
| if (cachedDuration != null && cachedDuration != Duration.zero) { | ||
| _delegate.duration.value = cachedDuration; | ||
| } |
There was a problem hiding this comment.
You should add comments describing what you are doing in such cases, because it's only obvious to you currently. And after some time even you can forget what this code does.
|
@DaemonWoo, our headquarters took a look at this PR and have expressed a slight disturbance due to this freelance task having so much review cycles, with amount of review comments not decrementing. This means that this task isn't what we are willing to keep this way, it grew a lot and took too much time already, with its completion date still being unknown. Since this project isn't a charity or some kind of personal project, I've been asked to make a sad decision to stop this cycle, break it at this point. I've invested my personal resources as well, so it's unfortunate for me as well. However, company standards don't allow me to do anything about it. At this point I'll close the PR and unassign the issue. However, if you would like to, you can always try again with a different issue. Thank you very much for all this time, all this effort you've put into the code! |
Resolves #618
Synopsis
Display audio files as a player with audio playback option, cover with integration test.Solution
Implement a global AudioPlayer class to handle audio files playback, including seek, pause, and duration tracking.
Cover the functionality with tests.
Checklist
k::labels applied