Skip to content
This repository was archived by the owner on Apr 14, 2026. It is now read-only.

Implement audio player for audio file attachments in Chat #618#1619

Closed
DaemonWoo wants to merge 76 commits intoteam113:mainfrom
DaemonWoo:feature/audio-player
Closed

Implement audio player for audio file attachments in Chat #618#1619
DaemonWoo wants to merge 76 commits intoteam113:mainfrom
DaemonWoo:feature/audio-player

Conversation

@DaemonWoo
Copy link
Copy Markdown
Contributor

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

  • Created PR:
    • In draft mode
    • Name contains issue reference
    • Has type and k:: labels applied
  • Before review:
    • Documentation is updated (if required)
    • Tests are updated (if required)
    • Changes conform code style
    • CHANGELOG entry is added (if required)
    • FCM (final commit message) is posted or updated
    • Draft mode is removed
  • Review is completed and changes are approved
    • FCM (final commit message) is approved
  • Before merge:
    • Milestone is set
    • PR's name and description are correct and up-to-date
    • All temporary labels are removed

@DaemonWoo DaemonWoo changed the title Implement audio player Implement audio player for audio file attachments in Chat #618 Feb 24, 2026
@DaemonWoo DaemonWoo force-pushed the feature/audio-player branch from e5aa03c to ab9bd10 Compare March 1, 2026 10:34
@SleepySquash SleepySquash added this to the 0.10.0 milestone Mar 2, 2026
Copy link
Copy Markdown
Contributor

@SleepySquash SleepySquash left a comment

Choose a reason for hiding this comment

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

@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 🥲

Comment thread lib/ui/worker/audio.dart Outdated
Comment on lines 65 to 67
await Get.deleteAll();
Get.put<AudioWorker>(MockAudioWorker());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@DaemonWoo,

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@DaemonWoo,

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@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.

Comment thread test/e2e/configuration.dart Outdated
Comment thread test/e2e/mock/audio.dart Outdated
Comment thread test/e2e/steps/audio_player.dart Outdated
Comment on lines +78 to +91
'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);
},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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".

Comment thread lib/ui/worker/audio/active_session.dart Outdated
Comment thread lib/ui/worker/audio/active_session.dart Outdated
Comment thread lib/ui/worker/audio/active_session.dart Outdated
Comment thread lib/ui/widget/audio_player/view.dart
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

@DaemonWoo DaemonWoo Mar 30, 2026

Choose a reason for hiding this comment

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

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?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@DaemonWoo, what's your opinion? Do you think it's ok or not? And why?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@DaemonWoo, we'll still need to modify the HomeController in this case, I guess?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@SleepySquash Yes, we will

@DaemonWoo DaemonWoo requested a review from SleepySquash April 1, 2026 09:02
@SleepySquash
Copy link
Copy Markdown
Contributor

@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.

@SleepySquash SleepySquash modified the milestones: 0.10.1, 0.11.0 Apr 6, 2026
Comment on lines +124 to +154
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,
);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment thread lib/ui/widget/audio_player/view.dart Outdated
Comment on lines +43 to +44
/// Indicates uploading progress.
final Widget? progress;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

A property should have a property documentation.

Comment on lines +46 to +47
/// Callback, called when [source] fetch fails with `403` status code.
final Future<AudioSource?> Function()? onForbidden;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

What is [source]?

Comment on lines +53 to +60
return GetBuilder(
init: AudioPlayerController(
Get.find(),
item: item,
onForbidden: onForbidden,
),
tag: item.id.val,
builder: (AudioPlayerController c) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment thread lib/ui/worker/audio.dart Outdated
Comment on lines +164 to +166
if (_playRequestId == playId) await stop();
} finally {
if (_playRequestId == playId) _delegate.isLoading.value = false;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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?

Comment thread test/e2e/mock/audio.dart
Comment on lines +25 to +28
/// Mocked [AudioWorker] to use in the tests.
class MockAudioWorker extends AudioWorker {
MockAudioWorker() : super(delegate: DummyDelegate());
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Do we need this class at all if we could just do AudioWorker(DummyDelegate())?

Comment thread test/e2e/mock/audio.dart
break;
}

position.value = position.value + const Duration(seconds: 1);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Comment thread lib/ui/worker/audio.dart Outdated
Comment on lines +131 to +134
final cachedDuration = _durationCache[item.id];
if (cachedDuration != null && cachedDuration != Duration.zero) {
_delegate.duration.value = cachedDuration;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

@SleepySquash SleepySquash added enhancement Improvement of existing features or bugfix feature New feature or request k::UI/UX UI (user interface) and UX (user experience) changes k::refactor Refactor changes of existing code wontfix This will not be worked on obsolete Resolving doesn't matter or is not required anymore labels Apr 6, 2026
@SleepySquash
Copy link
Copy Markdown
Contributor

@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!

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

enhancement Improvement of existing features or bugfix feature New feature or request k::refactor Refactor changes of existing code k::UI/UX UI (user interface) and UX (user experience) changes obsolete Resolving doesn't matter or is not required anymore wontfix This will not be worked on

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[$300] Implement audio player for audio file attachments in Chat

2 participants