Fix looping sounds playing only once (#9569)
#### Brief overview of PR changes/additions
Looping media (`loops=-1`) plays only once on Qt's FFmpeg multimedia
backend.
That backend ends a track by emitting `StoppedState` **first** and
`EndOfMedia` **second**, and it skips the `EndOfMedia` notification when
the playback engine was destroyed in between:
```cpp
if (currentPlaybackEngine)
stateChanged(QMediaPlayer::StoppedState);
if (currentPlaybackEngine)
mediaStatusChanged(QMediaPlayer::EndOfMedia);
```
Mudlet restarts a loop from its `EndOfMedia` handler, but
`TMedia::handlePlayerPlaybackStateChanged()` called `setSource(QUrl())`
immediately on `StoppedState`. That destroyed the engine, so
`EndOfMedia` never arrived and the loop died after one pass.
This defers the cleanup by one event-loop turn and re-checks the
playback state, so a loop restart can claim the player first while a
genuinely stopped player is still torn down.
#### Motivation for adding to Mudlet
Fixes #9566. Ambient/background music tracks stop after a single pass,
which affects any game using `Client.Media.Play` with `loops: -1`, MSP,
or `playMusicFile()`.
The immediate source clearing was introduced by #9237 as a
resource-cleanup measure. That cleanup is preserved here — it just runs
one event-loop turn later, and only if the player is still stopped.
#### Other info (issues closed, discussion etc)
Fixes #9566. Regression introduced by #9237 (merged 2026-04-29), so 4.21
and 4.22 are affected; 4.20.1 and earlier are not.
**Verified on Windows against the FFmpeg backend.** The bug reproduces
on unfixed code there, and this change resolves it without regressing
the cleanup that #9237 added.
It is worth being explicit that this could not be confirmed on macOS:
that platform ships `libdarwinmediaplugin`, which emits `EndOfMedia`
*before* `StoppedState` — the reverse ordering — so the loop restarts
before any cleanup runs and the bug cannot occur there at all. Reverting
the fix on macOS produced identical behaviour. The fix is therefore
specific to backends with the FFmpeg ordering, which is where the issue
was reported.
A functional test is included
(`test/functional_tests/TMediaLoopTest.cpp`) covering both halves of the
contract: a looping track must survive the stop/restart cycle, and a
one-shot track must still be cleaned up. It probes the backend before
asserting and **skips** unless that backend can both finish a clip and
emit `StoppedState` before `EndOfMedia`. Without that guard the looping
assertion passes on broken code, which is exactly what happened on
macOS. Expected results:
- FFmpeg backend → runs and asserts
- macOS darwin backend → skips (cannot exhibit the bug)
- Anywhere media stalls under `QT_QPA_PLATFORM=offscreen`, which the
functional suite forces → skips
So it may well skip in CI; that is intentional and preferable to a test
that always passes.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-02 13:11:37 -04:00
/***************************************************************************
* Copyright ( C ) 2026 by Mudlet Developers *
* *
* This program is free software ; you can redistribute it and / or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation ; either version 2 of the License , or *
* ( at your option ) any later version . *
* *
* This program is distributed in the hope that it will be useful , *
* but WITHOUT ANY WARRANTY ; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE . See the *
* GNU General Public License for more details . *
* *
* You should have received a copy of the GNU General Public License *
* along with this program ; if not , write to the *
* Free Software Foundation , Inc . , *
* 59 Temple Place - Suite 330 , Boston , MA 02111 - 1307 , USA . *
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
# include <QAudioOutput>
# include <QDeadlineTimer>
# include <QMediaPlayer>
# include <QTemporaryDir>
# include <QtTest/QtTest>
# include <chrono>
# include "Host.h"
# include "MudletInstanceCoordinator.h"
# include "TMedia.h"
# include "TMediaData.h"
# include "TelnetServerStub.h"
# include "dlgConnectionProfiles.h"
# include "mudlet.h"
# include "utils.h"
extern void qInitResources_mudlet ( ) ;
extern void qInitResources_qm ( ) ;
extern void qInitResources_additional_splash_screens ( ) ;
extern void qInitResources_mudlet_fonts_common ( ) ;
extern void qInitResources_mudlet_fonts_posix ( ) ;
void initializeQRCResourcesForMediaLoop ( ) ;
using namespace std : : chrono_literals ;
Fix: Sounds going silent when a file fails to load (#9612)
#### Brief overview of PR changes/additions
Follow-up hardening on top of #9569. Two ways a media player could end
up silent while still holding its source, plus the crash and the test
gaps found chasing them.
**A track that fails to load.** Nothing in `TMedia` listened for
`QMediaPlayer::errorOccurred`. A player that was already stopped when
its source failed reports no playback state change — and that signal is
what ends a playback, releases the source and raises `sysMediaFinished`.
The track fell silent holding a file nothing would ever release. The
error is now acted on.
**A track stopped while it is still loading.** Qt already considers an
unstarted player stopped, so `stop()` draws no state change out of one
mid-load, with the same result. This is not a narrow race: on an
asynchronous backend the Linux and Windows runners hit it every time.
`stopMedia()` now ends such a playback itself instead of waiting for a
report that is never coming.
Around those:
- The deferred source release is a single function shared by the stop,
error and teardown paths. Whether the player's own state gets a say
differs between them, so callers pass a `PlaybackEnd`: a stop needs it,
because a restart may be in flight and a player loading its next source
looks identical to a stopped one; a failure must *not* have it, because
a backend can report `PlayingState` for media it has just failed to
load.
- Re-sourcing a player goes through `claimSource()`, `continuePlaying()`
and `releaseSource()`, so the generation bump that tells a pending
release the track has moved on cannot be forgotten at a call site. A
missed bump is what let an earlier revision of #9569 clear the source of
a track that had just been restarted.
- `stopMedia()` empties the playlist, so an explicit stop cannot leave a
loop armed to restart itself from the `EndOfMedia` handler.
- `setupVideo()` failing now releases the source it claimed, and hides
the video widget only under the same `mediaWidget`/`mediaClose` guards
the deferred release uses — previously it could hide a label belonging
to an earlier clip.
- `src/dlgTriggerEditor.cpp` is here for one guard:
`runScheduledCleanReset()` repopulates itself from a `Host` that a
profile teardown has already destroyed. Unrelated to media, but it
crashed the media tests once they started running.
Behaviour worth knowing about when reviewing:
- `sysMediaFinished` now fires for a failed load and for a stop issued
mid-load, where nothing fired before. It is suppressed when the source
has already been released, where it would only have carried an empty
file name and path.
- `purgeMediaCache()` returns `false` when the directory could not be
fully removed, instead of always returning `true`.
- The closing closed caption is suppressed between the passes of a
looping track, and printed by `stopAllMediaPlayers()`, which releases
synchronously.
- `TMedia` gains three read-only diagnostics used by the tests —
`playersHoldingSource()`, `mediaPlayerCount()` and
`playersInPlayingState()`. A deferred release is otherwise unobservable:
`playingMedia()` has already dropped the player, the caption needs
captions enabled, and the video signal needs a widget.
Tests: new slots for a finite `loops=N` track, an explicit stop, an
unplayable source and a reused player. `probeBackend()` measures what
the backend can demonstrate — whether it starts playback at all, decodes
to `EndOfMedia`, orders `EndOfMedia` against `StoppedState`, starts
synchronously, and reports an undecodable file — and each test skips on
the capabilities it needs, printing what was measured.
`QT_MEDIA_BACKEND` is pinned to whatever `main.cpp` ships per platform,
since `QTEST_MAIN` does not run `main.cpp` and the tests were otherwise
exercising Qt's default backend rather than the one users get.
#### Motivation for adding to Mudlet
Both silent-failure cases are real and user-visible. A game sending a
filename Qt cannot decode, or a file gone from the media cache, would
kill the sound and leak the player's source while reporting nothing; and
`stopMusic()` shortly after `playMusic()` would leak the source every
time on Windows and Linux. A script chaining tracks off
`sysMediaFinished` waited forever in both cases.
They share a root cause with #9566: the deferred release could not tell
what the player was doing when its turn came around. The
`claimSource()`/`continuePlaying()`/`releaseSource()` encapsulation is
the durable part — it turns "remember to bump the counter" from a
convention into something the API does for you.
The test work matters as much as the fixes. The #9566 regression guard
was skipping on every CI job, so it was protecting nothing; both bugs
above were caught only once it actually ran.
#### Other info (issues closed, discussion etc)
Follow-up to #9569 / #9566. No issue number of its own.
Testing notes: the full functional suite passes — 66/66 on Linux, and
the media suite is green on the Ubuntu, Windows and both macOS jobs.
Backends differ in what they can demonstrate, so some media tests skip
by design. Under the environment ctest uses, six of the seven skip on
macOS: the `darwin` backend starts playback synchronously and delivers
`EndOfMedia` before `StoppedState`, so it can stage neither the claim
race nor the #9566 ordering, and it does not decode under
`QT_QPA_PLATFORM=offscreen`. Those paths run on the Ubuntu and Windows
FFmpeg builds, which is where both bugs in this PR were caught. Every
skip prints what the backend could not demonstrate and why, so an inert
guard is visible rather than silent.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-05 00:34:43 -04:00
// A skip is how these tests stay honest on a backend that cannot stage what they need. It is
// also how the whole file could go green everywhere and mean nothing, if a CI image lost its
// codecs or swapped its default backend - macOS already skips most of them by design, so a
// second platform quietly joining it would look no different. Runners known to carry a backend
// that can demonstrate everything here set MUDLET_MEDIA_TESTS_REQUIRE_PLAYBACK, which turns
// every capability skip into a failure and makes that loss a red build instead of silence.
# define SKIP_OR_FAIL_WITHOUT(reason) \
do { \
const QString incapable = ( reason ) ; \
if ( ! incapable . isEmpty ( ) ) { \
if ( qEnvironmentVariableIsSet ( " MUDLET_MEDIA_TESTS_REQUIRE_PLAYBACK " ) ) { \
QFAIL ( qPrintable ( qsl ( " MUDLET_MEDIA_TESTS_REQUIRE_PLAYBACK is set for this runner, so a backend that cannot do this is a failure and " \
" not a skip: %1 " ) \
. arg ( incapable ) ) ) ; \
} \
QSKIP ( qPrintable ( incapable ) ) ; \
} \
} while ( false )
Fix looping sounds playing only once (#9569)
#### Brief overview of PR changes/additions
Looping media (`loops=-1`) plays only once on Qt's FFmpeg multimedia
backend.
That backend ends a track by emitting `StoppedState` **first** and
`EndOfMedia` **second**, and it skips the `EndOfMedia` notification when
the playback engine was destroyed in between:
```cpp
if (currentPlaybackEngine)
stateChanged(QMediaPlayer::StoppedState);
if (currentPlaybackEngine)
mediaStatusChanged(QMediaPlayer::EndOfMedia);
```
Mudlet restarts a loop from its `EndOfMedia` handler, but
`TMedia::handlePlayerPlaybackStateChanged()` called `setSource(QUrl())`
immediately on `StoppedState`. That destroyed the engine, so
`EndOfMedia` never arrived and the loop died after one pass.
This defers the cleanup by one event-loop turn and re-checks the
playback state, so a loop restart can claim the player first while a
genuinely stopped player is still torn down.
#### Motivation for adding to Mudlet
Fixes #9566. Ambient/background music tracks stop after a single pass,
which affects any game using `Client.Media.Play` with `loops: -1`, MSP,
or `playMusicFile()`.
The immediate source clearing was introduced by #9237 as a
resource-cleanup measure. That cleanup is preserved here — it just runs
one event-loop turn later, and only if the player is still stopped.
#### Other info (issues closed, discussion etc)
Fixes #9566. Regression introduced by #9237 (merged 2026-04-29), so 4.21
and 4.22 are affected; 4.20.1 and earlier are not.
**Verified on Windows against the FFmpeg backend.** The bug reproduces
on unfixed code there, and this change resolves it without regressing
the cleanup that #9237 added.
It is worth being explicit that this could not be confirmed on macOS:
that platform ships `libdarwinmediaplugin`, which emits `EndOfMedia`
*before* `StoppedState` — the reverse ordering — so the loop restarts
before any cleanup runs and the bug cannot occur there at all. Reverting
the fix on macOS produced identical behaviour. The fix is therefore
specific to backends with the FFmpeg ordering, which is where the issue
was reported.
A functional test is included
(`test/functional_tests/TMediaLoopTest.cpp`) covering both halves of the
contract: a looping track must survive the stop/restart cycle, and a
one-shot track must still be cleaned up. It probes the backend before
asserting and **skips** unless that backend can both finish a clip and
emit `StoppedState` before `EndOfMedia`. Without that guard the looping
assertion passes on broken code, which is exactly what happened on
macOS. Expected results:
- FFmpeg backend → runs and asserts
- macOS darwin backend → skips (cannot exhibit the bug)
- Anywhere media stalls under `QT_QPA_PLATFORM=offscreen`, which the
functional suite forces → skips
So it may well skip in CI; that is intentional and preferable to a test
that always passes.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-02 13:11:37 -04:00
/*
Fix: Sounds going silent when a file fails to load (#9612)
#### Brief overview of PR changes/additions
Follow-up hardening on top of #9569. Two ways a media player could end
up silent while still holding its source, plus the crash and the test
gaps found chasing them.
**A track that fails to load.** Nothing in `TMedia` listened for
`QMediaPlayer::errorOccurred`. A player that was already stopped when
its source failed reports no playback state change — and that signal is
what ends a playback, releases the source and raises `sysMediaFinished`.
The track fell silent holding a file nothing would ever release. The
error is now acted on.
**A track stopped while it is still loading.** Qt already considers an
unstarted player stopped, so `stop()` draws no state change out of one
mid-load, with the same result. This is not a narrow race: on an
asynchronous backend the Linux and Windows runners hit it every time.
`stopMedia()` now ends such a playback itself instead of waiting for a
report that is never coming.
Around those:
- The deferred source release is a single function shared by the stop,
error and teardown paths. Whether the player's own state gets a say
differs between them, so callers pass a `PlaybackEnd`: a stop needs it,
because a restart may be in flight and a player loading its next source
looks identical to a stopped one; a failure must *not* have it, because
a backend can report `PlayingState` for media it has just failed to
load.
- Re-sourcing a player goes through `claimSource()`, `continuePlaying()`
and `releaseSource()`, so the generation bump that tells a pending
release the track has moved on cannot be forgotten at a call site. A
missed bump is what let an earlier revision of #9569 clear the source of
a track that had just been restarted.
- `stopMedia()` empties the playlist, so an explicit stop cannot leave a
loop armed to restart itself from the `EndOfMedia` handler.
- `setupVideo()` failing now releases the source it claimed, and hides
the video widget only under the same `mediaWidget`/`mediaClose` guards
the deferred release uses — previously it could hide a label belonging
to an earlier clip.
- `src/dlgTriggerEditor.cpp` is here for one guard:
`runScheduledCleanReset()` repopulates itself from a `Host` that a
profile teardown has already destroyed. Unrelated to media, but it
crashed the media tests once they started running.
Behaviour worth knowing about when reviewing:
- `sysMediaFinished` now fires for a failed load and for a stop issued
mid-load, where nothing fired before. It is suppressed when the source
has already been released, where it would only have carried an empty
file name and path.
- `purgeMediaCache()` returns `false` when the directory could not be
fully removed, instead of always returning `true`.
- The closing closed caption is suppressed between the passes of a
looping track, and printed by `stopAllMediaPlayers()`, which releases
synchronously.
- `TMedia` gains three read-only diagnostics used by the tests —
`playersHoldingSource()`, `mediaPlayerCount()` and
`playersInPlayingState()`. A deferred release is otherwise unobservable:
`playingMedia()` has already dropped the player, the caption needs
captions enabled, and the video signal needs a widget.
Tests: new slots for a finite `loops=N` track, an explicit stop, an
unplayable source and a reused player. `probeBackend()` measures what
the backend can demonstrate — whether it starts playback at all, decodes
to `EndOfMedia`, orders `EndOfMedia` against `StoppedState`, starts
synchronously, and reports an undecodable file — and each test skips on
the capabilities it needs, printing what was measured.
`QT_MEDIA_BACKEND` is pinned to whatever `main.cpp` ships per platform,
since `QTEST_MAIN` does not run `main.cpp` and the tests were otherwise
exercising Qt's default backend rather than the one users get.
#### Motivation for adding to Mudlet
Both silent-failure cases are real and user-visible. A game sending a
filename Qt cannot decode, or a file gone from the media cache, would
kill the sound and leak the player's source while reporting nothing; and
`stopMusic()` shortly after `playMusic()` would leak the source every
time on Windows and Linux. A script chaining tracks off
`sysMediaFinished` waited forever in both cases.
They share a root cause with #9566: the deferred release could not tell
what the player was doing when its turn came around. The
`claimSource()`/`continuePlaying()`/`releaseSource()` encapsulation is
the durable part — it turns "remember to bump the counter" from a
convention into something the API does for you.
The test work matters as much as the fixes. The #9566 regression guard
was skipping on every CI job, so it was protecting nothing; both bugs
above were caught only once it actually ran.
#### Other info (issues closed, discussion etc)
Follow-up to #9569 / #9566. No issue number of its own.
Testing notes: the full functional suite passes — 66/66 on Linux, and
the media suite is green on the Ubuntu, Windows and both macOS jobs.
Backends differ in what they can demonstrate, so some media tests skip
by design. Under the environment ctest uses, six of the seven skip on
macOS: the `darwin` backend starts playback synchronously and delivers
`EndOfMedia` before `StoppedState`, so it can stage neither the claim
race nor the #9566 ordering, and it does not decode under
`QT_QPA_PLATFORM=offscreen`. Those paths run on the Ubuntu and Windows
FFmpeg builds, which is where both bugs in this PR were caught. Every
skip prints what the backend could not demonstrate and why, so an inert
guard is visible rather than silent.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-05 00:34:43 -04:00
* Regression guard for " Client.Media loops=-1 plays once " ( issue # 9566 ) : the deferred media
* source release in TMedia , and the generation counters documented on TMediaPlayer that decide
* whether it still applies by the time its turn comes . The obligations that follow :
Fix looping sounds playing only once (#9569)
#### Brief overview of PR changes/additions
Looping media (`loops=-1`) plays only once on Qt's FFmpeg multimedia
backend.
That backend ends a track by emitting `StoppedState` **first** and
`EndOfMedia` **second**, and it skips the `EndOfMedia` notification when
the playback engine was destroyed in between:
```cpp
if (currentPlaybackEngine)
stateChanged(QMediaPlayer::StoppedState);
if (currentPlaybackEngine)
mediaStatusChanged(QMediaPlayer::EndOfMedia);
```
Mudlet restarts a loop from its `EndOfMedia` handler, but
`TMedia::handlePlayerPlaybackStateChanged()` called `setSource(QUrl())`
immediately on `StoppedState`. That destroyed the engine, so
`EndOfMedia` never arrived and the loop died after one pass.
This defers the cleanup by one event-loop turn and re-checks the
playback state, so a loop restart can claim the player first while a
genuinely stopped player is still torn down.
#### Motivation for adding to Mudlet
Fixes #9566. Ambient/background music tracks stop after a single pass,
which affects any game using `Client.Media.Play` with `loops: -1`, MSP,
or `playMusicFile()`.
The immediate source clearing was introduced by #9237 as a
resource-cleanup measure. That cleanup is preserved here — it just runs
one event-loop turn later, and only if the player is still stopped.
#### Other info (issues closed, discussion etc)
Fixes #9566. Regression introduced by #9237 (merged 2026-04-29), so 4.21
and 4.22 are affected; 4.20.1 and earlier are not.
**Verified on Windows against the FFmpeg backend.** The bug reproduces
on unfixed code there, and this change resolves it without regressing
the cleanup that #9237 added.
It is worth being explicit that this could not be confirmed on macOS:
that platform ships `libdarwinmediaplugin`, which emits `EndOfMedia`
*before* `StoppedState` — the reverse ordering — so the loop restarts
before any cleanup runs and the bug cannot occur there at all. Reverting
the fix on macOS produced identical behaviour. The fix is therefore
specific to backends with the FFmpeg ordering, which is where the issue
was reported.
A functional test is included
(`test/functional_tests/TMediaLoopTest.cpp`) covering both halves of the
contract: a looping track must survive the stop/restart cycle, and a
one-shot track must still be cleaned up. It probes the backend before
asserting and **skips** unless that backend can both finish a clip and
emit `StoppedState` before `EndOfMedia`. Without that guard the looping
assertion passes on broken code, which is exactly what happened on
macOS. Expected results:
- FFmpeg backend → runs and asserts
- macOS darwin backend → skips (cannot exhibit the bug)
- Anywhere media stalls under `QT_QPA_PLATFORM=offscreen`, which the
functional suite forces → skips
So it may well skip in CI; that is intentional and preferable to a test
that always passes.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-02 13:11:37 -04:00
*
Fix: Sounds going silent when a file fails to load (#9612)
#### Brief overview of PR changes/additions
Follow-up hardening on top of #9569. Two ways a media player could end
up silent while still holding its source, plus the crash and the test
gaps found chasing them.
**A track that fails to load.** Nothing in `TMedia` listened for
`QMediaPlayer::errorOccurred`. A player that was already stopped when
its source failed reports no playback state change — and that signal is
what ends a playback, releases the source and raises `sysMediaFinished`.
The track fell silent holding a file nothing would ever release. The
error is now acted on.
**A track stopped while it is still loading.** Qt already considers an
unstarted player stopped, so `stop()` draws no state change out of one
mid-load, with the same result. This is not a narrow race: on an
asynchronous backend the Linux and Windows runners hit it every time.
`stopMedia()` now ends such a playback itself instead of waiting for a
report that is never coming.
Around those:
- The deferred source release is a single function shared by the stop,
error and teardown paths. Whether the player's own state gets a say
differs between them, so callers pass a `PlaybackEnd`: a stop needs it,
because a restart may be in flight and a player loading its next source
looks identical to a stopped one; a failure must *not* have it, because
a backend can report `PlayingState` for media it has just failed to
load.
- Re-sourcing a player goes through `claimSource()`, `continuePlaying()`
and `releaseSource()`, so the generation bump that tells a pending
release the track has moved on cannot be forgotten at a call site. A
missed bump is what let an earlier revision of #9569 clear the source of
a track that had just been restarted.
- `stopMedia()` empties the playlist, so an explicit stop cannot leave a
loop armed to restart itself from the `EndOfMedia` handler.
- `setupVideo()` failing now releases the source it claimed, and hides
the video widget only under the same `mediaWidget`/`mediaClose` guards
the deferred release uses — previously it could hide a label belonging
to an earlier clip.
- `src/dlgTriggerEditor.cpp` is here for one guard:
`runScheduledCleanReset()` repopulates itself from a `Host` that a
profile teardown has already destroyed. Unrelated to media, but it
crashed the media tests once they started running.
Behaviour worth knowing about when reviewing:
- `sysMediaFinished` now fires for a failed load and for a stop issued
mid-load, where nothing fired before. It is suppressed when the source
has already been released, where it would only have carried an empty
file name and path.
- `purgeMediaCache()` returns `false` when the directory could not be
fully removed, instead of always returning `true`.
- The closing closed caption is suppressed between the passes of a
looping track, and printed by `stopAllMediaPlayers()`, which releases
synchronously.
- `TMedia` gains three read-only diagnostics used by the tests —
`playersHoldingSource()`, `mediaPlayerCount()` and
`playersInPlayingState()`. A deferred release is otherwise unobservable:
`playingMedia()` has already dropped the player, the caption needs
captions enabled, and the video signal needs a widget.
Tests: new slots for a finite `loops=N` track, an explicit stop, an
unplayable source and a reused player. `probeBackend()` measures what
the backend can demonstrate — whether it starts playback at all, decodes
to `EndOfMedia`, orders `EndOfMedia` against `StoppedState`, starts
synchronously, and reports an undecodable file — and each test skips on
the capabilities it needs, printing what was measured.
`QT_MEDIA_BACKEND` is pinned to whatever `main.cpp` ships per platform,
since `QTEST_MAIN` does not run `main.cpp` and the tests were otherwise
exercising Qt's default backend rather than the one users get.
#### Motivation for adding to Mudlet
Both silent-failure cases are real and user-visible. A game sending a
filename Qt cannot decode, or a file gone from the media cache, would
kill the sound and leak the player's source while reporting nothing; and
`stopMusic()` shortly after `playMusic()` would leak the source every
time on Windows and Linux. A script chaining tracks off
`sysMediaFinished` waited forever in both cases.
They share a root cause with #9566: the deferred release could not tell
what the player was doing when its turn came around. The
`claimSource()`/`continuePlaying()`/`releaseSource()` encapsulation is
the durable part — it turns "remember to bump the counter" from a
convention into something the API does for you.
The test work matters as much as the fixes. The #9566 regression guard
was skipping on every CI job, so it was protecting nothing; both bugs
above were caught only once it actually ran.
#### Other info (issues closed, discussion etc)
Follow-up to #9569 / #9566. No issue number of its own.
Testing notes: the full functional suite passes — 66/66 on Linux, and
the media suite is green on the Ubuntu, Windows and both macOS jobs.
Backends differ in what they can demonstrate, so some media tests skip
by design. Under the environment ctest uses, six of the seven skip on
macOS: the `darwin` backend starts playback synchronously and delivers
`EndOfMedia` before `StoppedState`, so it can stage neither the claim
race nor the #9566 ordering, and it does not decode under
`QT_QPA_PLATFORM=offscreen`. Those paths run on the Ubuntu and Windows
FFmpeg builds, which is where both bugs in this PR were caught. Every
skip prints what the backend could not demonstrate and why, so an inert
guard is visible rather than silent.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-05 00:34:43 -04:00
* - a looping track must survive the stop / restart cycle ( the # 9566 bug itself ) ;
* - a finite loops = N track must reach every pass , which goes through the playlist
* branch of the same handler ;
* - a track that genuinely finishes , or is stopped outright , must still release
* its source , or the resource release # 9237 added is lost ;
* - a source the backend cannot decode must release itself off the error signal , since
* the player was already stopped and no playback state change follows ;
* - a player re - sourced during the deferred turn , by a different track or by a
* continue = false restart of the same one , must keep the source it was given ;
* - each of those endings must raise sysMediaFinished exactly once , since releasing the
* source alone leaves a script chaining its next track off that event waiting forever ,
* and announcing twice re - enters any handler that stops the media it was told about .
Fix looping sounds playing only once (#9569)
#### Brief overview of PR changes/additions
Looping media (`loops=-1`) plays only once on Qt's FFmpeg multimedia
backend.
That backend ends a track by emitting `StoppedState` **first** and
`EndOfMedia` **second**, and it skips the `EndOfMedia` notification when
the playback engine was destroyed in between:
```cpp
if (currentPlaybackEngine)
stateChanged(QMediaPlayer::StoppedState);
if (currentPlaybackEngine)
mediaStatusChanged(QMediaPlayer::EndOfMedia);
```
Mudlet restarts a loop from its `EndOfMedia` handler, but
`TMedia::handlePlayerPlaybackStateChanged()` called `setSource(QUrl())`
immediately on `StoppedState`. That destroyed the engine, so
`EndOfMedia` never arrived and the loop died after one pass.
This defers the cleanup by one event-loop turn and re-checks the
playback state, so a loop restart can claim the player first while a
genuinely stopped player is still torn down.
#### Motivation for adding to Mudlet
Fixes #9566. Ambient/background music tracks stop after a single pass,
which affects any game using `Client.Media.Play` with `loops: -1`, MSP,
or `playMusicFile()`.
The immediate source clearing was introduced by #9237 as a
resource-cleanup measure. That cleanup is preserved here — it just runs
one event-loop turn later, and only if the player is still stopped.
#### Other info (issues closed, discussion etc)
Fixes #9566. Regression introduced by #9237 (merged 2026-04-29), so 4.21
and 4.22 are affected; 4.20.1 and earlier are not.
**Verified on Windows against the FFmpeg backend.** The bug reproduces
on unfixed code there, and this change resolves it without regressing
the cleanup that #9237 added.
It is worth being explicit that this could not be confirmed on macOS:
that platform ships `libdarwinmediaplugin`, which emits `EndOfMedia`
*before* `StoppedState` — the reverse ordering — so the loop restarts
before any cleanup runs and the bug cannot occur there at all. Reverting
the fix on macOS produced identical behaviour. The fix is therefore
specific to backends with the FFmpeg ordering, which is where the issue
was reported.
A functional test is included
(`test/functional_tests/TMediaLoopTest.cpp`) covering both halves of the
contract: a looping track must survive the stop/restart cycle, and a
one-shot track must still be cleaned up. It probes the backend before
asserting and **skips** unless that backend can both finish a clip and
emit `StoppedState` before `EndOfMedia`. Without that guard the looping
assertion passes on broken code, which is exactly what happened on
macOS. Expected results:
- FFmpeg backend → runs and asserts
- macOS darwin backend → skips (cannot exhibit the bug)
- Anywhere media stalls under `QT_QPA_PLATFORM=offscreen`, which the
functional suite forces → skips
So it may well skip in CI; that is intentional and preferable to a test
that always passes.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-02 13:11:37 -04:00
*
Fix: Sounds going silent when a file fails to load (#9612)
#### Brief overview of PR changes/additions
Follow-up hardening on top of #9569. Two ways a media player could end
up silent while still holding its source, plus the crash and the test
gaps found chasing them.
**A track that fails to load.** Nothing in `TMedia` listened for
`QMediaPlayer::errorOccurred`. A player that was already stopped when
its source failed reports no playback state change — and that signal is
what ends a playback, releases the source and raises `sysMediaFinished`.
The track fell silent holding a file nothing would ever release. The
error is now acted on.
**A track stopped while it is still loading.** Qt already considers an
unstarted player stopped, so `stop()` draws no state change out of one
mid-load, with the same result. This is not a narrow race: on an
asynchronous backend the Linux and Windows runners hit it every time.
`stopMedia()` now ends such a playback itself instead of waiting for a
report that is never coming.
Around those:
- The deferred source release is a single function shared by the stop,
error and teardown paths. Whether the player's own state gets a say
differs between them, so callers pass a `PlaybackEnd`: a stop needs it,
because a restart may be in flight and a player loading its next source
looks identical to a stopped one; a failure must *not* have it, because
a backend can report `PlayingState` for media it has just failed to
load.
- Re-sourcing a player goes through `claimSource()`, `continuePlaying()`
and `releaseSource()`, so the generation bump that tells a pending
release the track has moved on cannot be forgotten at a call site. A
missed bump is what let an earlier revision of #9569 clear the source of
a track that had just been restarted.
- `stopMedia()` empties the playlist, so an explicit stop cannot leave a
loop armed to restart itself from the `EndOfMedia` handler.
- `setupVideo()` failing now releases the source it claimed, and hides
the video widget only under the same `mediaWidget`/`mediaClose` guards
the deferred release uses — previously it could hide a label belonging
to an earlier clip.
- `src/dlgTriggerEditor.cpp` is here for one guard:
`runScheduledCleanReset()` repopulates itself from a `Host` that a
profile teardown has already destroyed. Unrelated to media, but it
crashed the media tests once they started running.
Behaviour worth knowing about when reviewing:
- `sysMediaFinished` now fires for a failed load and for a stop issued
mid-load, where nothing fired before. It is suppressed when the source
has already been released, where it would only have carried an empty
file name and path.
- `purgeMediaCache()` returns `false` when the directory could not be
fully removed, instead of always returning `true`.
- The closing closed caption is suppressed between the passes of a
looping track, and printed by `stopAllMediaPlayers()`, which releases
synchronously.
- `TMedia` gains three read-only diagnostics used by the tests —
`playersHoldingSource()`, `mediaPlayerCount()` and
`playersInPlayingState()`. A deferred release is otherwise unobservable:
`playingMedia()` has already dropped the player, the caption needs
captions enabled, and the video signal needs a widget.
Tests: new slots for a finite `loops=N` track, an explicit stop, an
unplayable source and a reused player. `probeBackend()` measures what
the backend can demonstrate — whether it starts playback at all, decodes
to `EndOfMedia`, orders `EndOfMedia` against `StoppedState`, starts
synchronously, and reports an undecodable file — and each test skips on
the capabilities it needs, printing what was measured.
`QT_MEDIA_BACKEND` is pinned to whatever `main.cpp` ships per platform,
since `QTEST_MAIN` does not run `main.cpp` and the tests were otherwise
exercising Qt's default backend rather than the one users get.
#### Motivation for adding to Mudlet
Both silent-failure cases are real and user-visible. A game sending a
filename Qt cannot decode, or a file gone from the media cache, would
kill the sound and leak the player's source while reporting nothing; and
`stopMusic()` shortly after `playMusic()` would leak the source every
time on Windows and Linux. A script chaining tracks off
`sysMediaFinished` waited forever in both cases.
They share a root cause with #9566: the deferred release could not tell
what the player was doing when its turn came around. The
`claimSource()`/`continuePlaying()`/`releaseSource()` encapsulation is
the durable part — it turns "remember to bump the counter" from a
convention into something the API does for you.
The test work matters as much as the fixes. The #9566 regression guard
was skipping on every CI job, so it was protecting nothing; both bugs
above were caught only once it actually ran.
#### Other info (issues closed, discussion etc)
Follow-up to #9569 / #9566. No issue number of its own.
Testing notes: the full functional suite passes — 66/66 on Linux, and
the media suite is green on the Ubuntu, Windows and both macOS jobs.
Backends differ in what they can demonstrate, so some media tests skip
by design. Under the environment ctest uses, six of the seven skip on
macOS: the `darwin` backend starts playback synchronously and delivers
`EndOfMedia` before `StoppedState`, so it can stage neither the claim
race nor the #9566 ordering, and it does not decode under
`QT_QPA_PLATFORM=offscreen`. Those paths run on the Ubuntu and Windows
FFmpeg builds, which is where both bugs in this PR were caught. Every
skip prints what the backend could not demonstrate and why, so an inert
guard is visible rather than silent.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-05 00:34:43 -04:00
* Which of those a backend can demonstrate varies , so probeBackend ( ) measures one up front and
* each test skips with what it found . CMakeLists . txt pins QT_MEDIA_BACKEND on the platforms
* where main . cpp does , and leaves it to Qt elsewhere , exactly as the shipped application does -
* so a skip reflects what users actually get .
Fix looping sounds playing only once (#9569)
#### Brief overview of PR changes/additions
Looping media (`loops=-1`) plays only once on Qt's FFmpeg multimedia
backend.
That backend ends a track by emitting `StoppedState` **first** and
`EndOfMedia` **second**, and it skips the `EndOfMedia` notification when
the playback engine was destroyed in between:
```cpp
if (currentPlaybackEngine)
stateChanged(QMediaPlayer::StoppedState);
if (currentPlaybackEngine)
mediaStatusChanged(QMediaPlayer::EndOfMedia);
```
Mudlet restarts a loop from its `EndOfMedia` handler, but
`TMedia::handlePlayerPlaybackStateChanged()` called `setSource(QUrl())`
immediately on `StoppedState`. That destroyed the engine, so
`EndOfMedia` never arrived and the loop died after one pass.
This defers the cleanup by one event-loop turn and re-checks the
playback state, so a loop restart can claim the player first while a
genuinely stopped player is still torn down.
#### Motivation for adding to Mudlet
Fixes #9566. Ambient/background music tracks stop after a single pass,
which affects any game using `Client.Media.Play` with `loops: -1`, MSP,
or `playMusicFile()`.
The immediate source clearing was introduced by #9237 as a
resource-cleanup measure. That cleanup is preserved here — it just runs
one event-loop turn later, and only if the player is still stopped.
#### Other info (issues closed, discussion etc)
Fixes #9566. Regression introduced by #9237 (merged 2026-04-29), so 4.21
and 4.22 are affected; 4.20.1 and earlier are not.
**Verified on Windows against the FFmpeg backend.** The bug reproduces
on unfixed code there, and this change resolves it without regressing
the cleanup that #9237 added.
It is worth being explicit that this could not be confirmed on macOS:
that platform ships `libdarwinmediaplugin`, which emits `EndOfMedia`
*before* `StoppedState` — the reverse ordering — so the loop restarts
before any cleanup runs and the bug cannot occur there at all. Reverting
the fix on macOS produced identical behaviour. The fix is therefore
specific to backends with the FFmpeg ordering, which is where the issue
was reported.
A functional test is included
(`test/functional_tests/TMediaLoopTest.cpp`) covering both halves of the
contract: a looping track must survive the stop/restart cycle, and a
one-shot track must still be cleaned up. It probes the backend before
asserting and **skips** unless that backend can both finish a clip and
emit `StoppedState` before `EndOfMedia`. Without that guard the looping
assertion passes on broken code, which is exactly what happened on
macOS. Expected results:
- FFmpeg backend → runs and asserts
- macOS darwin backend → skips (cannot exhibit the bug)
- Anywhere media stalls under `QT_QPA_PLATFORM=offscreen`, which the
functional suite forces → skips
So it may well skip in CI; that is intentional and preferable to a test
that always passes.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-02 13:11:37 -04:00
*/
class TMediaLoopTest : public QObject
{
Q_OBJECT
private :
TelnetServerStub * mpServer = nullptr ;
Fix: Sounds going silent when a file fails to load (#9612)
#### Brief overview of PR changes/additions
Follow-up hardening on top of #9569. Two ways a media player could end
up silent while still holding its source, plus the crash and the test
gaps found chasing them.
**A track that fails to load.** Nothing in `TMedia` listened for
`QMediaPlayer::errorOccurred`. A player that was already stopped when
its source failed reports no playback state change — and that signal is
what ends a playback, releases the source and raises `sysMediaFinished`.
The track fell silent holding a file nothing would ever release. The
error is now acted on.
**A track stopped while it is still loading.** Qt already considers an
unstarted player stopped, so `stop()` draws no state change out of one
mid-load, with the same result. This is not a narrow race: on an
asynchronous backend the Linux and Windows runners hit it every time.
`stopMedia()` now ends such a playback itself instead of waiting for a
report that is never coming.
Around those:
- The deferred source release is a single function shared by the stop,
error and teardown paths. Whether the player's own state gets a say
differs between them, so callers pass a `PlaybackEnd`: a stop needs it,
because a restart may be in flight and a player loading its next source
looks identical to a stopped one; a failure must *not* have it, because
a backend can report `PlayingState` for media it has just failed to
load.
- Re-sourcing a player goes through `claimSource()`, `continuePlaying()`
and `releaseSource()`, so the generation bump that tells a pending
release the track has moved on cannot be forgotten at a call site. A
missed bump is what let an earlier revision of #9569 clear the source of
a track that had just been restarted.
- `stopMedia()` empties the playlist, so an explicit stop cannot leave a
loop armed to restart itself from the `EndOfMedia` handler.
- `setupVideo()` failing now releases the source it claimed, and hides
the video widget only under the same `mediaWidget`/`mediaClose` guards
the deferred release uses — previously it could hide a label belonging
to an earlier clip.
- `src/dlgTriggerEditor.cpp` is here for one guard:
`runScheduledCleanReset()` repopulates itself from a `Host` that a
profile teardown has already destroyed. Unrelated to media, but it
crashed the media tests once they started running.
Behaviour worth knowing about when reviewing:
- `sysMediaFinished` now fires for a failed load and for a stop issued
mid-load, where nothing fired before. It is suppressed when the source
has already been released, where it would only have carried an empty
file name and path.
- `purgeMediaCache()` returns `false` when the directory could not be
fully removed, instead of always returning `true`.
- The closing closed caption is suppressed between the passes of a
looping track, and printed by `stopAllMediaPlayers()`, which releases
synchronously.
- `TMedia` gains three read-only diagnostics used by the tests —
`playersHoldingSource()`, `mediaPlayerCount()` and
`playersInPlayingState()`. A deferred release is otherwise unobservable:
`playingMedia()` has already dropped the player, the caption needs
captions enabled, and the video signal needs a widget.
Tests: new slots for a finite `loops=N` track, an explicit stop, an
unplayable source and a reused player. `probeBackend()` measures what
the backend can demonstrate — whether it starts playback at all, decodes
to `EndOfMedia`, orders `EndOfMedia` against `StoppedState`, starts
synchronously, and reports an undecodable file — and each test skips on
the capabilities it needs, printing what was measured.
`QT_MEDIA_BACKEND` is pinned to whatever `main.cpp` ships per platform,
since `QTEST_MAIN` does not run `main.cpp` and the tests were otherwise
exercising Qt's default backend rather than the one users get.
#### Motivation for adding to Mudlet
Both silent-failure cases are real and user-visible. A game sending a
filename Qt cannot decode, or a file gone from the media cache, would
kill the sound and leak the player's source while reporting nothing; and
`stopMusic()` shortly after `playMusic()` would leak the source every
time on Windows and Linux. A script chaining tracks off
`sysMediaFinished` waited forever in both cases.
They share a root cause with #9566: the deferred release could not tell
what the player was doing when its turn came around. The
`claimSource()`/`continuePlaying()`/`releaseSource()` encapsulation is
the durable part — it turns "remember to bump the counter" from a
convention into something the API does for you.
The test work matters as much as the fixes. The #9566 regression guard
was skipping on every CI job, so it was protecting nothing; both bugs
above were caught only once it actually ran.
#### Other info (issues closed, discussion etc)
Follow-up to #9569 / #9566. No issue number of its own.
Testing notes: the full functional suite passes — 66/66 on Linux, and
the media suite is green on the Ubuntu, Windows and both macOS jobs.
Backends differ in what they can demonstrate, so some media tests skip
by design. Under the environment ctest uses, six of the seven skip on
macOS: the `darwin` backend starts playback synchronously and delivers
`EndOfMedia` before `StoppedState`, so it can stage neither the claim
race nor the #9566 ordering, and it does not decode under
`QT_QPA_PLATFORM=offscreen`. Those paths run on the Ubuntu and Windows
FFmpeg builds, which is where both bugs in this PR were caught. Every
skip prints what the backend could not demonstrate and why, so an inert
guard is visible rather than silent.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-05 00:34:43 -04:00
Host * mpHost = nullptr ;
Fix looping sounds playing only once (#9569)
#### Brief overview of PR changes/additions
Looping media (`loops=-1`) plays only once on Qt's FFmpeg multimedia
backend.
That backend ends a track by emitting `StoppedState` **first** and
`EndOfMedia` **second**, and it skips the `EndOfMedia` notification when
the playback engine was destroyed in between:
```cpp
if (currentPlaybackEngine)
stateChanged(QMediaPlayer::StoppedState);
if (currentPlaybackEngine)
mediaStatusChanged(QMediaPlayer::EndOfMedia);
```
Mudlet restarts a loop from its `EndOfMedia` handler, but
`TMedia::handlePlayerPlaybackStateChanged()` called `setSource(QUrl())`
immediately on `StoppedState`. That destroyed the engine, so
`EndOfMedia` never arrived and the loop died after one pass.
This defers the cleanup by one event-loop turn and re-checks the
playback state, so a loop restart can claim the player first while a
genuinely stopped player is still torn down.
#### Motivation for adding to Mudlet
Fixes #9566. Ambient/background music tracks stop after a single pass,
which affects any game using `Client.Media.Play` with `loops: -1`, MSP,
or `playMusicFile()`.
The immediate source clearing was introduced by #9237 as a
resource-cleanup measure. That cleanup is preserved here — it just runs
one event-loop turn later, and only if the player is still stopped.
#### Other info (issues closed, discussion etc)
Fixes #9566. Regression introduced by #9237 (merged 2026-04-29), so 4.21
and 4.22 are affected; 4.20.1 and earlier are not.
**Verified on Windows against the FFmpeg backend.** The bug reproduces
on unfixed code there, and this change resolves it without regressing
the cleanup that #9237 added.
It is worth being explicit that this could not be confirmed on macOS:
that platform ships `libdarwinmediaplugin`, which emits `EndOfMedia`
*before* `StoppedState` — the reverse ordering — so the loop restarts
before any cleanup runs and the bug cannot occur there at all. Reverting
the fix on macOS produced identical behaviour. The fix is therefore
specific to backends with the FFmpeg ordering, which is where the issue
was reported.
A functional test is included
(`test/functional_tests/TMediaLoopTest.cpp`) covering both halves of the
contract: a looping track must survive the stop/restart cycle, and a
one-shot track must still be cleaned up. It probes the backend before
asserting and **skips** unless that backend can both finish a clip and
emit `StoppedState` before `EndOfMedia`. Without that guard the looping
assertion passes on broken code, which is exactly what happened on
macOS. Expected results:
- FFmpeg backend → runs and asserts
- macOS darwin backend → skips (cannot exhibit the bug)
- Anywhere media stalls under `QT_QPA_PLATFORM=offscreen`, which the
functional suite forces → skips
So it may well skip in CI; that is intentional and preferable to a test
that always passes.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-02 13:11:37 -04:00
const QString mHostname = " Test-Media-Loop " ;
const QString mPort = " 4012 " ;
const QString mLocalhost = " localhost " ;
// Length of the generated clip. Long enough that "still playing" cannot be an
// artefact of start-up latency, short enough to loop several times quickly.
static constexpr int clipMs = 400 ;
QTemporaryDir mProbeDir ;
Fix: Sounds going silent when a file fails to load (#9612)
#### Brief overview of PR changes/additions
Follow-up hardening on top of #9569. Two ways a media player could end
up silent while still holding its source, plus the crash and the test
gaps found chasing them.
**A track that fails to load.** Nothing in `TMedia` listened for
`QMediaPlayer::errorOccurred`. A player that was already stopped when
its source failed reports no playback state change — and that signal is
what ends a playback, releases the source and raises `sysMediaFinished`.
The track fell silent holding a file nothing would ever release. The
error is now acted on.
**A track stopped while it is still loading.** Qt already considers an
unstarted player stopped, so `stop()` draws no state change out of one
mid-load, with the same result. This is not a narrow race: on an
asynchronous backend the Linux and Windows runners hit it every time.
`stopMedia()` now ends such a playback itself instead of waiting for a
report that is never coming.
Around those:
- The deferred source release is a single function shared by the stop,
error and teardown paths. Whether the player's own state gets a say
differs between them, so callers pass a `PlaybackEnd`: a stop needs it,
because a restart may be in flight and a player loading its next source
looks identical to a stopped one; a failure must *not* have it, because
a backend can report `PlayingState` for media it has just failed to
load.
- Re-sourcing a player goes through `claimSource()`, `continuePlaying()`
and `releaseSource()`, so the generation bump that tells a pending
release the track has moved on cannot be forgotten at a call site. A
missed bump is what let an earlier revision of #9569 clear the source of
a track that had just been restarted.
- `stopMedia()` empties the playlist, so an explicit stop cannot leave a
loop armed to restart itself from the `EndOfMedia` handler.
- `setupVideo()` failing now releases the source it claimed, and hides
the video widget only under the same `mediaWidget`/`mediaClose` guards
the deferred release uses — previously it could hide a label belonging
to an earlier clip.
- `src/dlgTriggerEditor.cpp` is here for one guard:
`runScheduledCleanReset()` repopulates itself from a `Host` that a
profile teardown has already destroyed. Unrelated to media, but it
crashed the media tests once they started running.
Behaviour worth knowing about when reviewing:
- `sysMediaFinished` now fires for a failed load and for a stop issued
mid-load, where nothing fired before. It is suppressed when the source
has already been released, where it would only have carried an empty
file name and path.
- `purgeMediaCache()` returns `false` when the directory could not be
fully removed, instead of always returning `true`.
- The closing closed caption is suppressed between the passes of a
looping track, and printed by `stopAllMediaPlayers()`, which releases
synchronously.
- `TMedia` gains three read-only diagnostics used by the tests —
`playersHoldingSource()`, `mediaPlayerCount()` and
`playersInPlayingState()`. A deferred release is otherwise unobservable:
`playingMedia()` has already dropped the player, the caption needs
captions enabled, and the video signal needs a widget.
Tests: new slots for a finite `loops=N` track, an explicit stop, an
unplayable source and a reused player. `probeBackend()` measures what
the backend can demonstrate — whether it starts playback at all, decodes
to `EndOfMedia`, orders `EndOfMedia` against `StoppedState`, starts
synchronously, and reports an undecodable file — and each test skips on
the capabilities it needs, printing what was measured.
`QT_MEDIA_BACKEND` is pinned to whatever `main.cpp` ships per platform,
since `QTEST_MAIN` does not run `main.cpp` and the tests were otherwise
exercising Qt's default backend rather than the one users get.
#### Motivation for adding to Mudlet
Both silent-failure cases are real and user-visible. A game sending a
filename Qt cannot decode, or a file gone from the media cache, would
kill the sound and leak the player's source while reporting nothing; and
`stopMusic()` shortly after `playMusic()` would leak the source every
time on Windows and Linux. A script chaining tracks off
`sysMediaFinished` waited forever in both cases.
They share a root cause with #9566: the deferred release could not tell
what the player was doing when its turn came around. The
`claimSource()`/`continuePlaying()`/`releaseSource()` encapsulation is
the durable part — it turns "remember to bump the counter" from a
convention into something the API does for you.
The test work matters as much as the fixes. The #9566 regression guard
was skipping on every CI job, so it was protecting nothing; both bugs
above were caught only once it actually ran.
#### Other info (issues closed, discussion etc)
Follow-up to #9569 / #9566. No issue number of its own.
Testing notes: the full functional suite passes — 66/66 on Linux, and
the media suite is green on the Ubuntu, Windows and both macOS jobs.
Backends differ in what they can demonstrate, so some media tests skip
by design. Under the environment ctest uses, six of the seven skip on
macOS: the `darwin` backend starts playback synchronously and delivers
`EndOfMedia` before `StoppedState`, so it can stage neither the claim
race nor the #9566 ordering, and it does not decode under
`QT_QPA_PLATFORM=offscreen`. Those paths run on the Ubuntu and Windows
FFmpeg builds, which is where both bugs in this PR were caught. Every
skip prints what the backend could not demonstrate and why, so an inert
guard is visible rather than silent.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-05 00:34:43 -04:00
// Set when the backend never reaches PlayingState, so nothing below can even be started.
QString mCannotStartReason ;
// Set when the backend starts a clip but never decodes it through to EndOfMedia.
Fix looping sounds playing only once (#9569)
#### Brief overview of PR changes/additions
Looping media (`loops=-1`) plays only once on Qt's FFmpeg multimedia
backend.
That backend ends a track by emitting `StoppedState` **first** and
`EndOfMedia` **second**, and it skips the `EndOfMedia` notification when
the playback engine was destroyed in between:
```cpp
if (currentPlaybackEngine)
stateChanged(QMediaPlayer::StoppedState);
if (currentPlaybackEngine)
mediaStatusChanged(QMediaPlayer::EndOfMedia);
```
Mudlet restarts a loop from its `EndOfMedia` handler, but
`TMedia::handlePlayerPlaybackStateChanged()` called `setSource(QUrl())`
immediately on `StoppedState`. That destroyed the engine, so
`EndOfMedia` never arrived and the loop died after one pass.
This defers the cleanup by one event-loop turn and re-checks the
playback state, so a loop restart can claim the player first while a
genuinely stopped player is still torn down.
#### Motivation for adding to Mudlet
Fixes #9566. Ambient/background music tracks stop after a single pass,
which affects any game using `Client.Media.Play` with `loops: -1`, MSP,
or `playMusicFile()`.
The immediate source clearing was introduced by #9237 as a
resource-cleanup measure. That cleanup is preserved here — it just runs
one event-loop turn later, and only if the player is still stopped.
#### Other info (issues closed, discussion etc)
Fixes #9566. Regression introduced by #9237 (merged 2026-04-29), so 4.21
and 4.22 are affected; 4.20.1 and earlier are not.
**Verified on Windows against the FFmpeg backend.** The bug reproduces
on unfixed code there, and this change resolves it without regressing
the cleanup that #9237 added.
It is worth being explicit that this could not be confirmed on macOS:
that platform ships `libdarwinmediaplugin`, which emits `EndOfMedia`
*before* `StoppedState` — the reverse ordering — so the loop restarts
before any cleanup runs and the bug cannot occur there at all. Reverting
the fix on macOS produced identical behaviour. The fix is therefore
specific to backends with the FFmpeg ordering, which is where the issue
was reported.
A functional test is included
(`test/functional_tests/TMediaLoopTest.cpp`) covering both halves of the
contract: a looping track must survive the stop/restart cycle, and a
one-shot track must still be cleaned up. It probes the backend before
asserting and **skips** unless that backend can both finish a clip and
emit `StoppedState` before `EndOfMedia`. Without that guard the looping
assertion passes on broken code, which is exactly what happened on
macOS. Expected results:
- FFmpeg backend → runs and asserts
- macOS darwin backend → skips (cannot exhibit the bug)
- Anywhere media stalls under `QT_QPA_PLATFORM=offscreen`, which the
functional suite forces → skips
So it may well skip in CI; that is intentional and preferable to a test
that always passes.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-02 13:11:37 -04:00
QString mCannotPlayReason ;
// Set when the backend ends a track with EndOfMedia before StoppedState.
QString mWrongOrderReason ;
// Set when the backend starts playing synchronously, so a player that has just been
Fix: Sounds going silent when a file fails to load (#9612)
#### Brief overview of PR changes/additions
Follow-up hardening on top of #9569. Two ways a media player could end
up silent while still holding its source, plus the crash and the test
gaps found chasing them.
**A track that fails to load.** Nothing in `TMedia` listened for
`QMediaPlayer::errorOccurred`. A player that was already stopped when
its source failed reports no playback state change — and that signal is
what ends a playback, releases the source and raises `sysMediaFinished`.
The track fell silent holding a file nothing would ever release. The
error is now acted on.
**A track stopped while it is still loading.** Qt already considers an
unstarted player stopped, so `stop()` draws no state change out of one
mid-load, with the same result. This is not a narrow race: on an
asynchronous backend the Linux and Windows runners hit it every time.
`stopMedia()` now ends such a playback itself instead of waiting for a
report that is never coming.
Around those:
- The deferred source release is a single function shared by the stop,
error and teardown paths. Whether the player's own state gets a say
differs between them, so callers pass a `PlaybackEnd`: a stop needs it,
because a restart may be in flight and a player loading its next source
looks identical to a stopped one; a failure must *not* have it, because
a backend can report `PlayingState` for media it has just failed to
load.
- Re-sourcing a player goes through `claimSource()`, `continuePlaying()`
and `releaseSource()`, so the generation bump that tells a pending
release the track has moved on cannot be forgotten at a call site. A
missed bump is what let an earlier revision of #9569 clear the source of
a track that had just been restarted.
- `stopMedia()` empties the playlist, so an explicit stop cannot leave a
loop armed to restart itself from the `EndOfMedia` handler.
- `setupVideo()` failing now releases the source it claimed, and hides
the video widget only under the same `mediaWidget`/`mediaClose` guards
the deferred release uses — previously it could hide a label belonging
to an earlier clip.
- `src/dlgTriggerEditor.cpp` is here for one guard:
`runScheduledCleanReset()` repopulates itself from a `Host` that a
profile teardown has already destroyed. Unrelated to media, but it
crashed the media tests once they started running.
Behaviour worth knowing about when reviewing:
- `sysMediaFinished` now fires for a failed load and for a stop issued
mid-load, where nothing fired before. It is suppressed when the source
has already been released, where it would only have carried an empty
file name and path.
- `purgeMediaCache()` returns `false` when the directory could not be
fully removed, instead of always returning `true`.
- The closing closed caption is suppressed between the passes of a
looping track, and printed by `stopAllMediaPlayers()`, which releases
synchronously.
- `TMedia` gains three read-only diagnostics used by the tests —
`playersHoldingSource()`, `mediaPlayerCount()` and
`playersInPlayingState()`. A deferred release is otherwise unobservable:
`playingMedia()` has already dropped the player, the caption needs
captions enabled, and the video signal needs a widget.
Tests: new slots for a finite `loops=N` track, an explicit stop, an
unplayable source and a reused player. `probeBackend()` measures what
the backend can demonstrate — whether it starts playback at all, decodes
to `EndOfMedia`, orders `EndOfMedia` against `StoppedState`, starts
synchronously, and reports an undecodable file — and each test skips on
the capabilities it needs, printing what was measured.
`QT_MEDIA_BACKEND` is pinned to whatever `main.cpp` ships per platform,
since `QTEST_MAIN` does not run `main.cpp` and the tests were otherwise
exercising Qt's default backend rather than the one users get.
#### Motivation for adding to Mudlet
Both silent-failure cases are real and user-visible. A game sending a
filename Qt cannot decode, or a file gone from the media cache, would
kill the sound and leak the player's source while reporting nothing; and
`stopMusic()` shortly after `playMusic()` would leak the source every
time on Windows and Linux. A script chaining tracks off
`sysMediaFinished` waited forever in both cases.
They share a root cause with #9566: the deferred release could not tell
what the player was doing when its turn came around. The
`claimSource()`/`continuePlaying()`/`releaseSource()` encapsulation is
the durable part — it turns "remember to bump the counter" from a
convention into something the API does for you.
The test work matters as much as the fixes. The #9566 regression guard
was skipping on every CI job, so it was protecting nothing; both bugs
above were caught only once it actually ran.
#### Other info (issues closed, discussion etc)
Follow-up to #9569 / #9566. No issue number of its own.
Testing notes: the full functional suite passes — 66/66 on Linux, and
the media suite is green on the Ubuntu, Windows and both macOS jobs.
Backends differ in what they can demonstrate, so some media tests skip
by design. Under the environment ctest uses, six of the seven skip on
macOS: the `darwin` backend starts playback synchronously and delivers
`EndOfMedia` before `StoppedState`, so it can stage neither the claim
race nor the #9566 ordering, and it does not decode under
`QT_QPA_PLATFORM=offscreen`. Those paths run on the Ubuntu and Windows
FFmpeg builds, which is where both bugs in this PR were caught. Every
skip prints what the backend could not demonstrate and why, so an inert
guard is visible rather than silent.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-05 00:34:43 -04:00
// re-sourced can never be mistaken for a stopped one - which is the whole race the
// claim counter exists to settle.
Fix looping sounds playing only once (#9569)
#### Brief overview of PR changes/additions
Looping media (`loops=-1`) plays only once on Qt's FFmpeg multimedia
backend.
That backend ends a track by emitting `StoppedState` **first** and
`EndOfMedia` **second**, and it skips the `EndOfMedia` notification when
the playback engine was destroyed in between:
```cpp
if (currentPlaybackEngine)
stateChanged(QMediaPlayer::StoppedState);
if (currentPlaybackEngine)
mediaStatusChanged(QMediaPlayer::EndOfMedia);
```
Mudlet restarts a loop from its `EndOfMedia` handler, but
`TMedia::handlePlayerPlaybackStateChanged()` called `setSource(QUrl())`
immediately on `StoppedState`. That destroyed the engine, so
`EndOfMedia` never arrived and the loop died after one pass.
This defers the cleanup by one event-loop turn and re-checks the
playback state, so a loop restart can claim the player first while a
genuinely stopped player is still torn down.
#### Motivation for adding to Mudlet
Fixes #9566. Ambient/background music tracks stop after a single pass,
which affects any game using `Client.Media.Play` with `loops: -1`, MSP,
or `playMusicFile()`.
The immediate source clearing was introduced by #9237 as a
resource-cleanup measure. That cleanup is preserved here — it just runs
one event-loop turn later, and only if the player is still stopped.
#### Other info (issues closed, discussion etc)
Fixes #9566. Regression introduced by #9237 (merged 2026-04-29), so 4.21
and 4.22 are affected; 4.20.1 and earlier are not.
**Verified on Windows against the FFmpeg backend.** The bug reproduces
on unfixed code there, and this change resolves it without regressing
the cleanup that #9237 added.
It is worth being explicit that this could not be confirmed on macOS:
that platform ships `libdarwinmediaplugin`, which emits `EndOfMedia`
*before* `StoppedState` — the reverse ordering — so the loop restarts
before any cleanup runs and the bug cannot occur there at all. Reverting
the fix on macOS produced identical behaviour. The fix is therefore
specific to backends with the FFmpeg ordering, which is where the issue
was reported.
A functional test is included
(`test/functional_tests/TMediaLoopTest.cpp`) covering both halves of the
contract: a looping track must survive the stop/restart cycle, and a
one-shot track must still be cleaned up. It probes the backend before
asserting and **skips** unless that backend can both finish a clip and
emit `StoppedState` before `EndOfMedia`. Without that guard the looping
assertion passes on broken code, which is exactly what happened on
macOS. Expected results:
- FFmpeg backend → runs and asserts
- macOS darwin backend → skips (cannot exhibit the bug)
- Anywhere media stalls under `QT_QPA_PLATFORM=offscreen`, which the
functional suite forces → skips
So it may well skip in CI; that is intentional and preferable to a test
that always passes.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-02 13:11:37 -04:00
QString mSynchronousStartReason ;
Fix: Sounds going silent when a file fails to load (#9612)
#### Brief overview of PR changes/additions
Follow-up hardening on top of #9569. Two ways a media player could end
up silent while still holding its source, plus the crash and the test
gaps found chasing them.
**A track that fails to load.** Nothing in `TMedia` listened for
`QMediaPlayer::errorOccurred`. A player that was already stopped when
its source failed reports no playback state change — and that signal is
what ends a playback, releases the source and raises `sysMediaFinished`.
The track fell silent holding a file nothing would ever release. The
error is now acted on.
**A track stopped while it is still loading.** Qt already considers an
unstarted player stopped, so `stop()` draws no state change out of one
mid-load, with the same result. This is not a narrow race: on an
asynchronous backend the Linux and Windows runners hit it every time.
`stopMedia()` now ends such a playback itself instead of waiting for a
report that is never coming.
Around those:
- The deferred source release is a single function shared by the stop,
error and teardown paths. Whether the player's own state gets a say
differs between them, so callers pass a `PlaybackEnd`: a stop needs it,
because a restart may be in flight and a player loading its next source
looks identical to a stopped one; a failure must *not* have it, because
a backend can report `PlayingState` for media it has just failed to
load.
- Re-sourcing a player goes through `claimSource()`, `continuePlaying()`
and `releaseSource()`, so the generation bump that tells a pending
release the track has moved on cannot be forgotten at a call site. A
missed bump is what let an earlier revision of #9569 clear the source of
a track that had just been restarted.
- `stopMedia()` empties the playlist, so an explicit stop cannot leave a
loop armed to restart itself from the `EndOfMedia` handler.
- `setupVideo()` failing now releases the source it claimed, and hides
the video widget only under the same `mediaWidget`/`mediaClose` guards
the deferred release uses — previously it could hide a label belonging
to an earlier clip.
- `src/dlgTriggerEditor.cpp` is here for one guard:
`runScheduledCleanReset()` repopulates itself from a `Host` that a
profile teardown has already destroyed. Unrelated to media, but it
crashed the media tests once they started running.
Behaviour worth knowing about when reviewing:
- `sysMediaFinished` now fires for a failed load and for a stop issued
mid-load, where nothing fired before. It is suppressed when the source
has already been released, where it would only have carried an empty
file name and path.
- `purgeMediaCache()` returns `false` when the directory could not be
fully removed, instead of always returning `true`.
- The closing closed caption is suppressed between the passes of a
looping track, and printed by `stopAllMediaPlayers()`, which releases
synchronously.
- `TMedia` gains three read-only diagnostics used by the tests —
`playersHoldingSource()`, `mediaPlayerCount()` and
`playersInPlayingState()`. A deferred release is otherwise unobservable:
`playingMedia()` has already dropped the player, the caption needs
captions enabled, and the video signal needs a widget.
Tests: new slots for a finite `loops=N` track, an explicit stop, an
unplayable source and a reused player. `probeBackend()` measures what
the backend can demonstrate — whether it starts playback at all, decodes
to `EndOfMedia`, orders `EndOfMedia` against `StoppedState`, starts
synchronously, and reports an undecodable file — and each test skips on
the capabilities it needs, printing what was measured.
`QT_MEDIA_BACKEND` is pinned to whatever `main.cpp` ships per platform,
since `QTEST_MAIN` does not run `main.cpp` and the tests were otherwise
exercising Qt's default backend rather than the one users get.
#### Motivation for adding to Mudlet
Both silent-failure cases are real and user-visible. A game sending a
filename Qt cannot decode, or a file gone from the media cache, would
kill the sound and leak the player's source while reporting nothing; and
`stopMusic()` shortly after `playMusic()` would leak the source every
time on Windows and Linux. A script chaining tracks off
`sysMediaFinished` waited forever in both cases.
They share a root cause with #9566: the deferred release could not tell
what the player was doing when its turn came around. The
`claimSource()`/`continuePlaying()`/`releaseSource()` encapsulation is
the durable part — it turns "remember to bump the counter" from a
convention into something the API does for you.
The test work matters as much as the fixes. The #9566 regression guard
was skipping on every CI job, so it was protecting nothing; both bugs
above were caught only once it actually ran.
#### Other info (issues closed, discussion etc)
Follow-up to #9569 / #9566. No issue number of its own.
Testing notes: the full functional suite passes — 66/66 on Linux, and
the media suite is green on the Ubuntu, Windows and both macOS jobs.
Backends differ in what they can demonstrate, so some media tests skip
by design. Under the environment ctest uses, six of the seven skip on
macOS: the `darwin` backend starts playback synchronously and delivers
`EndOfMedia` before `StoppedState`, so it can stage neither the claim
race nor the #9566 ordering, and it does not decode under
`QT_QPA_PLATFORM=offscreen`. Those paths run on the Ubuntu and Windows
FFmpeg builds, which is where both bugs in this PR were caught. Every
skip prints what the backend could not demonstrate and why, so an inert
guard is visible rather than silent.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-05 00:34:43 -04:00
// Set when the backend does not report an undecodable file as an error.
QString mNoLoadErrorReason ;
Fix looping sounds playing only once (#9569)
#### Brief overview of PR changes/additions
Looping media (`loops=-1`) plays only once on Qt's FFmpeg multimedia
backend.
That backend ends a track by emitting `StoppedState` **first** and
`EndOfMedia` **second**, and it skips the `EndOfMedia` notification when
the playback engine was destroyed in between:
```cpp
if (currentPlaybackEngine)
stateChanged(QMediaPlayer::StoppedState);
if (currentPlaybackEngine)
mediaStatusChanged(QMediaPlayer::EndOfMedia);
```
Mudlet restarts a loop from its `EndOfMedia` handler, but
`TMedia::handlePlayerPlaybackStateChanged()` called `setSource(QUrl())`
immediately on `StoppedState`. That destroyed the engine, so
`EndOfMedia` never arrived and the loop died after one pass.
This defers the cleanup by one event-loop turn and re-checks the
playback state, so a loop restart can claim the player first while a
genuinely stopped player is still torn down.
#### Motivation for adding to Mudlet
Fixes #9566. Ambient/background music tracks stop after a single pass,
which affects any game using `Client.Media.Play` with `loops: -1`, MSP,
or `playMusicFile()`.
The immediate source clearing was introduced by #9237 as a
resource-cleanup measure. That cleanup is preserved here — it just runs
one event-loop turn later, and only if the player is still stopped.
#### Other info (issues closed, discussion etc)
Fixes #9566. Regression introduced by #9237 (merged 2026-04-29), so 4.21
and 4.22 are affected; 4.20.1 and earlier are not.
**Verified on Windows against the FFmpeg backend.** The bug reproduces
on unfixed code there, and this change resolves it without regressing
the cleanup that #9237 added.
It is worth being explicit that this could not be confirmed on macOS:
that platform ships `libdarwinmediaplugin`, which emits `EndOfMedia`
*before* `StoppedState` — the reverse ordering — so the loop restarts
before any cleanup runs and the bug cannot occur there at all. Reverting
the fix on macOS produced identical behaviour. The fix is therefore
specific to backends with the FFmpeg ordering, which is where the issue
was reported.
A functional test is included
(`test/functional_tests/TMediaLoopTest.cpp`) covering both halves of the
contract: a looping track must survive the stop/restart cycle, and a
one-shot track must still be cleaned up. It probes the backend before
asserting and **skips** unless that backend can both finish a clip and
emit `StoppedState` before `EndOfMedia`. Without that guard the looping
assertion passes on broken code, which is exactly what happened on
macOS. Expected results:
- FFmpeg backend → runs and asserts
- macOS darwin backend → skips (cannot exhibit the bug)
- Anywhere media stalls under `QT_QPA_PLATFORM=offscreen`, which the
functional suite forces → skips
So it may well skip in CI; that is intentional and preferable to a test
that always passes.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-02 13:11:37 -04:00
private slots :
void initTestCase ( )
{
initializeQRCResourcesForMediaLoop ( ) ;
probeBackend ( ) ;
}
void init ( )
{
mpServer = new TelnetServerStub ( qApp ) ;
mpServer - > start ( mLocalhost , mPort . toUShort ( ) ) ;
mudlet : : start ( ) ;
mudlet : : self ( ) - > setupConfig ( ) ;
mudlet : : self ( ) - > takeOwnershipOfInstanceCoordinator ( std : : make_unique < MudletInstanceCoordinator > ( " MudletInstanceCoordinator " ) ) ;
mudlet : : self ( ) - > init ( ) ;
mudlet : : self ( ) - > setStorePasswordsSecurely ( false ) ;
deleteProfileDirectory ( mHostname ) ;
}
// A looping track must still be playing well after its first pass would have
// ended. Before the fix the first StoppedState cleared the source, EndOfMedia
// was never delivered and the player dropped out of the playing set for good.
void test_loopingTrackKeepsPlayingPastFirstPass ( )
{
Fix: Sounds going silent when a file fails to load (#9612)
#### Brief overview of PR changes/additions
Follow-up hardening on top of #9569. Two ways a media player could end
up silent while still holding its source, plus the crash and the test
gaps found chasing them.
**A track that fails to load.** Nothing in `TMedia` listened for
`QMediaPlayer::errorOccurred`. A player that was already stopped when
its source failed reports no playback state change — and that signal is
what ends a playback, releases the source and raises `sysMediaFinished`.
The track fell silent holding a file nothing would ever release. The
error is now acted on.
**A track stopped while it is still loading.** Qt already considers an
unstarted player stopped, so `stop()` draws no state change out of one
mid-load, with the same result. This is not a narrow race: on an
asynchronous backend the Linux and Windows runners hit it every time.
`stopMedia()` now ends such a playback itself instead of waiting for a
report that is never coming.
Around those:
- The deferred source release is a single function shared by the stop,
error and teardown paths. Whether the player's own state gets a say
differs between them, so callers pass a `PlaybackEnd`: a stop needs it,
because a restart may be in flight and a player loading its next source
looks identical to a stopped one; a failure must *not* have it, because
a backend can report `PlayingState` for media it has just failed to
load.
- Re-sourcing a player goes through `claimSource()`, `continuePlaying()`
and `releaseSource()`, so the generation bump that tells a pending
release the track has moved on cannot be forgotten at a call site. A
missed bump is what let an earlier revision of #9569 clear the source of
a track that had just been restarted.
- `stopMedia()` empties the playlist, so an explicit stop cannot leave a
loop armed to restart itself from the `EndOfMedia` handler.
- `setupVideo()` failing now releases the source it claimed, and hides
the video widget only under the same `mediaWidget`/`mediaClose` guards
the deferred release uses — previously it could hide a label belonging
to an earlier clip.
- `src/dlgTriggerEditor.cpp` is here for one guard:
`runScheduledCleanReset()` repopulates itself from a `Host` that a
profile teardown has already destroyed. Unrelated to media, but it
crashed the media tests once they started running.
Behaviour worth knowing about when reviewing:
- `sysMediaFinished` now fires for a failed load and for a stop issued
mid-load, where nothing fired before. It is suppressed when the source
has already been released, where it would only have carried an empty
file name and path.
- `purgeMediaCache()` returns `false` when the directory could not be
fully removed, instead of always returning `true`.
- The closing closed caption is suppressed between the passes of a
looping track, and printed by `stopAllMediaPlayers()`, which releases
synchronously.
- `TMedia` gains three read-only diagnostics used by the tests —
`playersHoldingSource()`, `mediaPlayerCount()` and
`playersInPlayingState()`. A deferred release is otherwise unobservable:
`playingMedia()` has already dropped the player, the caption needs
captions enabled, and the video signal needs a widget.
Tests: new slots for a finite `loops=N` track, an explicit stop, an
unplayable source and a reused player. `probeBackend()` measures what
the backend can demonstrate — whether it starts playback at all, decodes
to `EndOfMedia`, orders `EndOfMedia` against `StoppedState`, starts
synchronously, and reports an undecodable file — and each test skips on
the capabilities it needs, printing what was measured.
`QT_MEDIA_BACKEND` is pinned to whatever `main.cpp` ships per platform,
since `QTEST_MAIN` does not run `main.cpp` and the tests were otherwise
exercising Qt's default backend rather than the one users get.
#### Motivation for adding to Mudlet
Both silent-failure cases are real and user-visible. A game sending a
filename Qt cannot decode, or a file gone from the media cache, would
kill the sound and leak the player's source while reporting nothing; and
`stopMusic()` shortly after `playMusic()` would leak the source every
time on Windows and Linux. A script chaining tracks off
`sysMediaFinished` waited forever in both cases.
They share a root cause with #9566: the deferred release could not tell
what the player was doing when its turn came around. The
`claimSource()`/`continuePlaying()`/`releaseSource()` encapsulation is
the durable part — it turns "remember to bump the counter" from a
convention into something the API does for you.
The test work matters as much as the fixes. The #9566 regression guard
was skipping on every CI job, so it was protecting nothing; both bugs
above were caught only once it actually ran.
#### Other info (issues closed, discussion etc)
Follow-up to #9569 / #9566. No issue number of its own.
Testing notes: the full functional suite passes — 66/66 on Linux, and
the media suite is green on the Ubuntu, Windows and both macOS jobs.
Backends differ in what they can demonstrate, so some media tests skip
by design. Under the environment ctest uses, six of the seven skip on
macOS: the `darwin` backend starts playback synchronously and delivers
`EndOfMedia` before `StoppedState`, so it can stage neither the claim
race nor the #9566 ordering, and it does not decode under
`QT_QPA_PLATFORM=offscreen`. Those paths run on the Ubuntu and Windows
FFmpeg builds, which is where both bugs in this PR were caught. Every
skip prints what the backend could not demonstrate and why, so an inert
guard is visible rather than silent.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-05 00:34:43 -04:00
SKIP_OR_FAIL_WITHOUT ( mCannotPlayReason ) ;
SKIP_OR_FAIL_WITHOUT ( mWrongOrderReason ) ;
Fix looping sounds playing only once (#9569)
#### Brief overview of PR changes/additions
Looping media (`loops=-1`) plays only once on Qt's FFmpeg multimedia
backend.
That backend ends a track by emitting `StoppedState` **first** and
`EndOfMedia` **second**, and it skips the `EndOfMedia` notification when
the playback engine was destroyed in between:
```cpp
if (currentPlaybackEngine)
stateChanged(QMediaPlayer::StoppedState);
if (currentPlaybackEngine)
mediaStatusChanged(QMediaPlayer::EndOfMedia);
```
Mudlet restarts a loop from its `EndOfMedia` handler, but
`TMedia::handlePlayerPlaybackStateChanged()` called `setSource(QUrl())`
immediately on `StoppedState`. That destroyed the engine, so
`EndOfMedia` never arrived and the loop died after one pass.
This defers the cleanup by one event-loop turn and re-checks the
playback state, so a loop restart can claim the player first while a
genuinely stopped player is still torn down.
#### Motivation for adding to Mudlet
Fixes #9566. Ambient/background music tracks stop after a single pass,
which affects any game using `Client.Media.Play` with `loops: -1`, MSP,
or `playMusicFile()`.
The immediate source clearing was introduced by #9237 as a
resource-cleanup measure. That cleanup is preserved here — it just runs
one event-loop turn later, and only if the player is still stopped.
#### Other info (issues closed, discussion etc)
Fixes #9566. Regression introduced by #9237 (merged 2026-04-29), so 4.21
and 4.22 are affected; 4.20.1 and earlier are not.
**Verified on Windows against the FFmpeg backend.** The bug reproduces
on unfixed code there, and this change resolves it without regressing
the cleanup that #9237 added.
It is worth being explicit that this could not be confirmed on macOS:
that platform ships `libdarwinmediaplugin`, which emits `EndOfMedia`
*before* `StoppedState` — the reverse ordering — so the loop restarts
before any cleanup runs and the bug cannot occur there at all. Reverting
the fix on macOS produced identical behaviour. The fix is therefore
specific to backends with the FFmpeg ordering, which is where the issue
was reported.
A functional test is included
(`test/functional_tests/TMediaLoopTest.cpp`) covering both halves of the
contract: a looping track must survive the stop/restart cycle, and a
one-shot track must still be cleaned up. It probes the backend before
asserting and **skips** unless that backend can both finish a clip and
emit `StoppedState` before `EndOfMedia`. Without that guard the looping
assertion passes on broken code, which is exactly what happened on
macOS. Expected results:
- FFmpeg backend → runs and asserts
- macOS darwin backend → skips (cannot exhibit the bug)
- Anywhere media stalls under `QT_QPA_PLATFORM=offscreen`, which the
functional suite forces → skips
So it may well skip in CI; that is intentional and preferable to a test
that always passes.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-02 13:11:37 -04:00
auto * media = startProfileAndGetMedia ( ) ;
QVERIFY ( media ) ;
const QString fileName = writeClip ( qsl ( " loop.wav " ) ) ;
Fix: Sounds going silent when a file fails to load (#9612)
#### Brief overview of PR changes/additions
Follow-up hardening on top of #9569. Two ways a media player could end
up silent while still holding its source, plus the crash and the test
gaps found chasing them.
**A track that fails to load.** Nothing in `TMedia` listened for
`QMediaPlayer::errorOccurred`. A player that was already stopped when
its source failed reports no playback state change — and that signal is
what ends a playback, releases the source and raises `sysMediaFinished`.
The track fell silent holding a file nothing would ever release. The
error is now acted on.
**A track stopped while it is still loading.** Qt already considers an
unstarted player stopped, so `stop()` draws no state change out of one
mid-load, with the same result. This is not a narrow race: on an
asynchronous backend the Linux and Windows runners hit it every time.
`stopMedia()` now ends such a playback itself instead of waiting for a
report that is never coming.
Around those:
- The deferred source release is a single function shared by the stop,
error and teardown paths. Whether the player's own state gets a say
differs between them, so callers pass a `PlaybackEnd`: a stop needs it,
because a restart may be in flight and a player loading its next source
looks identical to a stopped one; a failure must *not* have it, because
a backend can report `PlayingState` for media it has just failed to
load.
- Re-sourcing a player goes through `claimSource()`, `continuePlaying()`
and `releaseSource()`, so the generation bump that tells a pending
release the track has moved on cannot be forgotten at a call site. A
missed bump is what let an earlier revision of #9569 clear the source of
a track that had just been restarted.
- `stopMedia()` empties the playlist, so an explicit stop cannot leave a
loop armed to restart itself from the `EndOfMedia` handler.
- `setupVideo()` failing now releases the source it claimed, and hides
the video widget only under the same `mediaWidget`/`mediaClose` guards
the deferred release uses — previously it could hide a label belonging
to an earlier clip.
- `src/dlgTriggerEditor.cpp` is here for one guard:
`runScheduledCleanReset()` repopulates itself from a `Host` that a
profile teardown has already destroyed. Unrelated to media, but it
crashed the media tests once they started running.
Behaviour worth knowing about when reviewing:
- `sysMediaFinished` now fires for a failed load and for a stop issued
mid-load, where nothing fired before. It is suppressed when the source
has already been released, where it would only have carried an empty
file name and path.
- `purgeMediaCache()` returns `false` when the directory could not be
fully removed, instead of always returning `true`.
- The closing closed caption is suppressed between the passes of a
looping track, and printed by `stopAllMediaPlayers()`, which releases
synchronously.
- `TMedia` gains three read-only diagnostics used by the tests —
`playersHoldingSource()`, `mediaPlayerCount()` and
`playersInPlayingState()`. A deferred release is otherwise unobservable:
`playingMedia()` has already dropped the player, the caption needs
captions enabled, and the video signal needs a widget.
Tests: new slots for a finite `loops=N` track, an explicit stop, an
unplayable source and a reused player. `probeBackend()` measures what
the backend can demonstrate — whether it starts playback at all, decodes
to `EndOfMedia`, orders `EndOfMedia` against `StoppedState`, starts
synchronously, and reports an undecodable file — and each test skips on
the capabilities it needs, printing what was measured.
`QT_MEDIA_BACKEND` is pinned to whatever `main.cpp` ships per platform,
since `QTEST_MAIN` does not run `main.cpp` and the tests were otherwise
exercising Qt's default backend rather than the one users get.
#### Motivation for adding to Mudlet
Both silent-failure cases are real and user-visible. A game sending a
filename Qt cannot decode, or a file gone from the media cache, would
kill the sound and leak the player's source while reporting nothing; and
`stopMusic()` shortly after `playMusic()` would leak the source every
time on Windows and Linux. A script chaining tracks off
`sysMediaFinished` waited forever in both cases.
They share a root cause with #9566: the deferred release could not tell
what the player was doing when its turn came around. The
`claimSource()`/`continuePlaying()`/`releaseSource()` encapsulation is
the durable part — it turns "remember to bump the counter" from a
convention into something the API does for you.
The test work matters as much as the fixes. The #9566 regression guard
was skipping on every CI job, so it was protecting nothing; both bugs
above were caught only once it actually ran.
#### Other info (issues closed, discussion etc)
Follow-up to #9569 / #9566. No issue number of its own.
Testing notes: the full functional suite passes — 66/66 on Linux, and
the media suite is green on the Ubuntu, Windows and both macOS jobs.
Backends differ in what they can demonstrate, so some media tests skip
by design. Under the environment ctest uses, six of the seven skip on
macOS: the `darwin` backend starts playback synchronously and delivers
`EndOfMedia` before `StoppedState`, so it can stage neither the claim
race nor the #9566 ordering, and it does not decode under
`QT_QPA_PLATFORM=offscreen`. Those paths run on the Ubuntu and Windows
FFmpeg builds, which is where both bugs in this PR were caught. Every
skip prints what the backend could not demonstrate and why, so an inert
guard is visible rather than silent.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-05 00:34:43 -04:00
QVERIFY ( ! fileName . isEmpty ( ) ) ;
Fix looping sounds playing only once (#9569)
#### Brief overview of PR changes/additions
Looping media (`loops=-1`) plays only once on Qt's FFmpeg multimedia
backend.
That backend ends a track by emitting `StoppedState` **first** and
`EndOfMedia` **second**, and it skips the `EndOfMedia` notification when
the playback engine was destroyed in between:
```cpp
if (currentPlaybackEngine)
stateChanged(QMediaPlayer::StoppedState);
if (currentPlaybackEngine)
mediaStatusChanged(QMediaPlayer::EndOfMedia);
```
Mudlet restarts a loop from its `EndOfMedia` handler, but
`TMedia::handlePlayerPlaybackStateChanged()` called `setSource(QUrl())`
immediately on `StoppedState`. That destroyed the engine, so
`EndOfMedia` never arrived and the loop died after one pass.
This defers the cleanup by one event-loop turn and re-checks the
playback state, so a loop restart can claim the player first while a
genuinely stopped player is still torn down.
#### Motivation for adding to Mudlet
Fixes #9566. Ambient/background music tracks stop after a single pass,
which affects any game using `Client.Media.Play` with `loops: -1`, MSP,
or `playMusicFile()`.
The immediate source clearing was introduced by #9237 as a
resource-cleanup measure. That cleanup is preserved here — it just runs
one event-loop turn later, and only if the player is still stopped.
#### Other info (issues closed, discussion etc)
Fixes #9566. Regression introduced by #9237 (merged 2026-04-29), so 4.21
and 4.22 are affected; 4.20.1 and earlier are not.
**Verified on Windows against the FFmpeg backend.** The bug reproduces
on unfixed code there, and this change resolves it without regressing
the cleanup that #9237 added.
It is worth being explicit that this could not be confirmed on macOS:
that platform ships `libdarwinmediaplugin`, which emits `EndOfMedia`
*before* `StoppedState` — the reverse ordering — so the loop restarts
before any cleanup runs and the bug cannot occur there at all. Reverting
the fix on macOS produced identical behaviour. The fix is therefore
specific to backends with the FFmpeg ordering, which is where the issue
was reported.
A functional test is included
(`test/functional_tests/TMediaLoopTest.cpp`) covering both halves of the
contract: a looping track must survive the stop/restart cycle, and a
one-shot track must still be cleaned up. It probes the backend before
asserting and **skips** unless that backend can both finish a clip and
emit `StoppedState` before `EndOfMedia`. Without that guard the looping
assertion passes on broken code, which is exactly what happened on
macOS. Expected results:
- FFmpeg backend → runs and asserts
- macOS darwin backend → skips (cannot exhibit the bug)
- Anywhere media stalls under `QT_QPA_PLATFORM=offscreen`, which the
functional suite forces → skips
So it may well skip in CI; that is intentional and preferable to a test
that always passes.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-02 13:11:37 -04:00
TMediaData data = clipData ( fileName ) ;
data . setMediaLoops ( TMediaData : : MediaLoopsRepeat ) ;
media - > playMedia ( data ) ;
QVERIFY2 ( waitForPlaying ( media , fileName ) , " The looping track never started playing. " ) ;
Fix: Sounds going silent when a file fails to load (#9612)
#### Brief overview of PR changes/additions
Follow-up hardening on top of #9569. Two ways a media player could end
up silent while still holding its source, plus the crash and the test
gaps found chasing them.
**A track that fails to load.** Nothing in `TMedia` listened for
`QMediaPlayer::errorOccurred`. A player that was already stopped when
its source failed reports no playback state change — and that signal is
what ends a playback, releases the source and raises `sysMediaFinished`.
The track fell silent holding a file nothing would ever release. The
error is now acted on.
**A track stopped while it is still loading.** Qt already considers an
unstarted player stopped, so `stop()` draws no state change out of one
mid-load, with the same result. This is not a narrow race: on an
asynchronous backend the Linux and Windows runners hit it every time.
`stopMedia()` now ends such a playback itself instead of waiting for a
report that is never coming.
Around those:
- The deferred source release is a single function shared by the stop,
error and teardown paths. Whether the player's own state gets a say
differs between them, so callers pass a `PlaybackEnd`: a stop needs it,
because a restart may be in flight and a player loading its next source
looks identical to a stopped one; a failure must *not* have it, because
a backend can report `PlayingState` for media it has just failed to
load.
- Re-sourcing a player goes through `claimSource()`, `continuePlaying()`
and `releaseSource()`, so the generation bump that tells a pending
release the track has moved on cannot be forgotten at a call site. A
missed bump is what let an earlier revision of #9569 clear the source of
a track that had just been restarted.
- `stopMedia()` empties the playlist, so an explicit stop cannot leave a
loop armed to restart itself from the `EndOfMedia` handler.
- `setupVideo()` failing now releases the source it claimed, and hides
the video widget only under the same `mediaWidget`/`mediaClose` guards
the deferred release uses — previously it could hide a label belonging
to an earlier clip.
- `src/dlgTriggerEditor.cpp` is here for one guard:
`runScheduledCleanReset()` repopulates itself from a `Host` that a
profile teardown has already destroyed. Unrelated to media, but it
crashed the media tests once they started running.
Behaviour worth knowing about when reviewing:
- `sysMediaFinished` now fires for a failed load and for a stop issued
mid-load, where nothing fired before. It is suppressed when the source
has already been released, where it would only have carried an empty
file name and path.
- `purgeMediaCache()` returns `false` when the directory could not be
fully removed, instead of always returning `true`.
- The closing closed caption is suppressed between the passes of a
looping track, and printed by `stopAllMediaPlayers()`, which releases
synchronously.
- `TMedia` gains three read-only diagnostics used by the tests —
`playersHoldingSource()`, `mediaPlayerCount()` and
`playersInPlayingState()`. A deferred release is otherwise unobservable:
`playingMedia()` has already dropped the player, the caption needs
captions enabled, and the video signal needs a widget.
Tests: new slots for a finite `loops=N` track, an explicit stop, an
unplayable source and a reused player. `probeBackend()` measures what
the backend can demonstrate — whether it starts playback at all, decodes
to `EndOfMedia`, orders `EndOfMedia` against `StoppedState`, starts
synchronously, and reports an undecodable file — and each test skips on
the capabilities it needs, printing what was measured.
`QT_MEDIA_BACKEND` is pinned to whatever `main.cpp` ships per platform,
since `QTEST_MAIN` does not run `main.cpp` and the tests were otherwise
exercising Qt's default backend rather than the one users get.
#### Motivation for adding to Mudlet
Both silent-failure cases are real and user-visible. A game sending a
filename Qt cannot decode, or a file gone from the media cache, would
kill the sound and leak the player's source while reporting nothing; and
`stopMusic()` shortly after `playMusic()` would leak the source every
time on Windows and Linux. A script chaining tracks off
`sysMediaFinished` waited forever in both cases.
They share a root cause with #9566: the deferred release could not tell
what the player was doing when its turn came around. The
`claimSource()`/`continuePlaying()`/`releaseSource()` encapsulation is
the durable part — it turns "remember to bump the counter" from a
convention into something the API does for you.
The test work matters as much as the fixes. The #9566 regression guard
was skipping on every CI job, so it was protecting nothing; both bugs
above were caught only once it actually ran.
#### Other info (issues closed, discussion etc)
Follow-up to #9569 / #9566. No issue number of its own.
Testing notes: the full functional suite passes — 66/66 on Linux, and
the media suite is green on the Ubuntu, Windows and both macOS jobs.
Backends differ in what they can demonstrate, so some media tests skip
by design. Under the environment ctest uses, six of the seven skip on
macOS: the `darwin` backend starts playback synchronously and delivers
`EndOfMedia` before `StoppedState`, so it can stage neither the claim
race nor the #9566 ordering, and it does not decode under
`QT_QPA_PLATFORM=offscreen`. Those paths run on the Ubuntu and Windows
FFmpeg builds, which is where both bugs in this PR were caught. Every
skip prints what the backend could not demonstrate and why, so an inert
guard is visible rather than silent.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-05 00:34:43 -04:00
// Span several passes so a single missed restart cannot pass by luck, and land off a
// clip boundary: StoppedState and the EndOfMedia that restarts the loop are separate
// signals, and in the window between them a healthy player reads as not playing.
QTest : : qWait ( clipMs * 4 + clipMs / 2 ) ;
QVERIFY2 ( waitForPlaying ( media , fileName , 2 s ) , " A loops=-1 track stopped after its first pass - the StoppedState cleanup suppressed EndOfMedia and the loop never restarted. " ) ;
}
// Every pass of a finite loops=N track after the first comes from the playlist branch of
// the same EndOfMedia handler, which the indefinite-loop test never reaches. Its final
// pass is also the one place a continuation ends and the deferred cleanup must take over.
// Both hold whichever way round the backend emits EndOfMedia and StoppedState, so unlike
// the loop test this one needs no mWrongOrderReason gate.
void test_finiteLoopsReachEveryPass ( )
{
SKIP_OR_FAIL_WITHOUT ( mCannotPlayReason ) ;
auto * media = startProfileAndGetMedia ( ) ;
QVERIFY ( media ) ;
const QString fileName = writeClip ( qsl ( " finite.wav " ) ) ;
QVERIFY ( ! fileName . isEmpty ( ) ) ;
TMediaData data = clipData ( fileName ) ;
data . setMediaLoops ( 3 ) ;
media - > playMedia ( data ) ;
QVERIFY2 ( waitForPlaying ( media , fileName ) , " The finite-loop track never started playing. " ) ;
// Into the second pass, which only happens if the playlist advanced.
QTest : : qWait ( clipMs + clipMs / 2 ) ;
QVERIFY2 ( waitForPlaying ( media , fileName , 2 s ) , " A loops=3 track stopped after its first pass - the playlist never advanced to the next entry. " ) ;
// ...and the last pass must still hand back to the cleanup rather than loop forever.
const bool cleanedUp = QTest : : qWaitFor (
[ & ] ( ) {
return ! playing ( media , fileName ) & & media - > playersHoldingSource ( ) = = 0 ;
} ,
QDeadlineTimer ( 10 s ) ) ;
Fix looping sounds playing only once (#9569)
#### Brief overview of PR changes/additions
Looping media (`loops=-1`) plays only once on Qt's FFmpeg multimedia
backend.
That backend ends a track by emitting `StoppedState` **first** and
`EndOfMedia` **second**, and it skips the `EndOfMedia` notification when
the playback engine was destroyed in between:
```cpp
if (currentPlaybackEngine)
stateChanged(QMediaPlayer::StoppedState);
if (currentPlaybackEngine)
mediaStatusChanged(QMediaPlayer::EndOfMedia);
```
Mudlet restarts a loop from its `EndOfMedia` handler, but
`TMedia::handlePlayerPlaybackStateChanged()` called `setSource(QUrl())`
immediately on `StoppedState`. That destroyed the engine, so
`EndOfMedia` never arrived and the loop died after one pass.
This defers the cleanup by one event-loop turn and re-checks the
playback state, so a loop restart can claim the player first while a
genuinely stopped player is still torn down.
#### Motivation for adding to Mudlet
Fixes #9566. Ambient/background music tracks stop after a single pass,
which affects any game using `Client.Media.Play` with `loops: -1`, MSP,
or `playMusicFile()`.
The immediate source clearing was introduced by #9237 as a
resource-cleanup measure. That cleanup is preserved here — it just runs
one event-loop turn later, and only if the player is still stopped.
#### Other info (issues closed, discussion etc)
Fixes #9566. Regression introduced by #9237 (merged 2026-04-29), so 4.21
and 4.22 are affected; 4.20.1 and earlier are not.
**Verified on Windows against the FFmpeg backend.** The bug reproduces
on unfixed code there, and this change resolves it without regressing
the cleanup that #9237 added.
It is worth being explicit that this could not be confirmed on macOS:
that platform ships `libdarwinmediaplugin`, which emits `EndOfMedia`
*before* `StoppedState` — the reverse ordering — so the loop restarts
before any cleanup runs and the bug cannot occur there at all. Reverting
the fix on macOS produced identical behaviour. The fix is therefore
specific to backends with the FFmpeg ordering, which is where the issue
was reported.
A functional test is included
(`test/functional_tests/TMediaLoopTest.cpp`) covering both halves of the
contract: a looping track must survive the stop/restart cycle, and a
one-shot track must still be cleaned up. It probes the backend before
asserting and **skips** unless that backend can both finish a clip and
emit `StoppedState` before `EndOfMedia`. Without that guard the looping
assertion passes on broken code, which is exactly what happened on
macOS. Expected results:
- FFmpeg backend → runs and asserts
- macOS darwin backend → skips (cannot exhibit the bug)
- Anywhere media stalls under `QT_QPA_PLATFORM=offscreen`, which the
functional suite forces → skips
So it may well skip in CI; that is intentional and preferable to a test
that always passes.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-02 13:11:37 -04:00
Fix: Sounds going silent when a file fails to load (#9612)
#### Brief overview of PR changes/additions
Follow-up hardening on top of #9569. Two ways a media player could end
up silent while still holding its source, plus the crash and the test
gaps found chasing them.
**A track that fails to load.** Nothing in `TMedia` listened for
`QMediaPlayer::errorOccurred`. A player that was already stopped when
its source failed reports no playback state change — and that signal is
what ends a playback, releases the source and raises `sysMediaFinished`.
The track fell silent holding a file nothing would ever release. The
error is now acted on.
**A track stopped while it is still loading.** Qt already considers an
unstarted player stopped, so `stop()` draws no state change out of one
mid-load, with the same result. This is not a narrow race: on an
asynchronous backend the Linux and Windows runners hit it every time.
`stopMedia()` now ends such a playback itself instead of waiting for a
report that is never coming.
Around those:
- The deferred source release is a single function shared by the stop,
error and teardown paths. Whether the player's own state gets a say
differs between them, so callers pass a `PlaybackEnd`: a stop needs it,
because a restart may be in flight and a player loading its next source
looks identical to a stopped one; a failure must *not* have it, because
a backend can report `PlayingState` for media it has just failed to
load.
- Re-sourcing a player goes through `claimSource()`, `continuePlaying()`
and `releaseSource()`, so the generation bump that tells a pending
release the track has moved on cannot be forgotten at a call site. A
missed bump is what let an earlier revision of #9569 clear the source of
a track that had just been restarted.
- `stopMedia()` empties the playlist, so an explicit stop cannot leave a
loop armed to restart itself from the `EndOfMedia` handler.
- `setupVideo()` failing now releases the source it claimed, and hides
the video widget only under the same `mediaWidget`/`mediaClose` guards
the deferred release uses — previously it could hide a label belonging
to an earlier clip.
- `src/dlgTriggerEditor.cpp` is here for one guard:
`runScheduledCleanReset()` repopulates itself from a `Host` that a
profile teardown has already destroyed. Unrelated to media, but it
crashed the media tests once they started running.
Behaviour worth knowing about when reviewing:
- `sysMediaFinished` now fires for a failed load and for a stop issued
mid-load, where nothing fired before. It is suppressed when the source
has already been released, where it would only have carried an empty
file name and path.
- `purgeMediaCache()` returns `false` when the directory could not be
fully removed, instead of always returning `true`.
- The closing closed caption is suppressed between the passes of a
looping track, and printed by `stopAllMediaPlayers()`, which releases
synchronously.
- `TMedia` gains three read-only diagnostics used by the tests —
`playersHoldingSource()`, `mediaPlayerCount()` and
`playersInPlayingState()`. A deferred release is otherwise unobservable:
`playingMedia()` has already dropped the player, the caption needs
captions enabled, and the video signal needs a widget.
Tests: new slots for a finite `loops=N` track, an explicit stop, an
unplayable source and a reused player. `probeBackend()` measures what
the backend can demonstrate — whether it starts playback at all, decodes
to `EndOfMedia`, orders `EndOfMedia` against `StoppedState`, starts
synchronously, and reports an undecodable file — and each test skips on
the capabilities it needs, printing what was measured.
`QT_MEDIA_BACKEND` is pinned to whatever `main.cpp` ships per platform,
since `QTEST_MAIN` does not run `main.cpp` and the tests were otherwise
exercising Qt's default backend rather than the one users get.
#### Motivation for adding to Mudlet
Both silent-failure cases are real and user-visible. A game sending a
filename Qt cannot decode, or a file gone from the media cache, would
kill the sound and leak the player's source while reporting nothing; and
`stopMusic()` shortly after `playMusic()` would leak the source every
time on Windows and Linux. A script chaining tracks off
`sysMediaFinished` waited forever in both cases.
They share a root cause with #9566: the deferred release could not tell
what the player was doing when its turn came around. The
`claimSource()`/`continuePlaying()`/`releaseSource()` encapsulation is
the durable part — it turns "remember to bump the counter" from a
convention into something the API does for you.
The test work matters as much as the fixes. The #9566 regression guard
was skipping on every CI job, so it was protecting nothing; both bugs
above were caught only once it actually ran.
#### Other info (issues closed, discussion etc)
Follow-up to #9569 / #9566. No issue number of its own.
Testing notes: the full functional suite passes — 66/66 on Linux, and
the media suite is green on the Ubuntu, Windows and both macOS jobs.
Backends differ in what they can demonstrate, so some media tests skip
by design. Under the environment ctest uses, six of the seven skip on
macOS: the `darwin` backend starts playback synchronously and delivers
`EndOfMedia` before `StoppedState`, so it can stage neither the claim
race nor the #9566 ordering, and it does not decode under
`QT_QPA_PLATFORM=offscreen`. Those paths run on the Ubuntu and Windows
FFmpeg builds, which is where both bugs in this PR were caught. Every
skip prints what the backend could not demonstrate and why, so an inert
guard is visible rather than silent.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-05 00:34:43 -04:00
QVERIFY2 ( cleanedUp , " A loops=3 track never finished and released its source - the deferred cleanup did not take over from the last pass. " ) ;
Fix looping sounds playing only once (#9569)
#### Brief overview of PR changes/additions
Looping media (`loops=-1`) plays only once on Qt's FFmpeg multimedia
backend.
That backend ends a track by emitting `StoppedState` **first** and
`EndOfMedia` **second**, and it skips the `EndOfMedia` notification when
the playback engine was destroyed in between:
```cpp
if (currentPlaybackEngine)
stateChanged(QMediaPlayer::StoppedState);
if (currentPlaybackEngine)
mediaStatusChanged(QMediaPlayer::EndOfMedia);
```
Mudlet restarts a loop from its `EndOfMedia` handler, but
`TMedia::handlePlayerPlaybackStateChanged()` called `setSource(QUrl())`
immediately on `StoppedState`. That destroyed the engine, so
`EndOfMedia` never arrived and the loop died after one pass.
This defers the cleanup by one event-loop turn and re-checks the
playback state, so a loop restart can claim the player first while a
genuinely stopped player is still torn down.
#### Motivation for adding to Mudlet
Fixes #9566. Ambient/background music tracks stop after a single pass,
which affects any game using `Client.Media.Play` with `loops: -1`, MSP,
or `playMusicFile()`.
The immediate source clearing was introduced by #9237 as a
resource-cleanup measure. That cleanup is preserved here — it just runs
one event-loop turn later, and only if the player is still stopped.
#### Other info (issues closed, discussion etc)
Fixes #9566. Regression introduced by #9237 (merged 2026-04-29), so 4.21
and 4.22 are affected; 4.20.1 and earlier are not.
**Verified on Windows against the FFmpeg backend.** The bug reproduces
on unfixed code there, and this change resolves it without regressing
the cleanup that #9237 added.
It is worth being explicit that this could not be confirmed on macOS:
that platform ships `libdarwinmediaplugin`, which emits `EndOfMedia`
*before* `StoppedState` — the reverse ordering — so the loop restarts
before any cleanup runs and the bug cannot occur there at all. Reverting
the fix on macOS produced identical behaviour. The fix is therefore
specific to backends with the FFmpeg ordering, which is where the issue
was reported.
A functional test is included
(`test/functional_tests/TMediaLoopTest.cpp`) covering both halves of the
contract: a looping track must survive the stop/restart cycle, and a
one-shot track must still be cleaned up. It probes the backend before
asserting and **skips** unless that backend can both finish a clip and
emit `StoppedState` before `EndOfMedia`. Without that guard the looping
assertion passes on broken code, which is exactly what happened on
macOS. Expected results:
- FFmpeg backend → runs and asserts
- macOS darwin backend → skips (cannot exhibit the bug)
- Anywhere media stalls under `QT_QPA_PLATFORM=offscreen`, which the
functional suite forces → skips
So it may well skip in CI; that is intentional and preferable to a test
that always passes.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-02 13:11:37 -04:00
}
fix: pausing a track and then playing a different one left you with silence (#9710)
#### Brief overview of PR changes/additions
- Ending a track is now done before its player is handed to the next
one, so pausing a sound (or music) and playing a *different* file plays
it, and reports the paused track's own ending rather than a spurious
`sysMediaFinished` carrying the new request's key and tag.
- A `play()` call now owns the player it is setting up, so a
`sysMediaFinished` handler that starts media of its own can no longer
take that player over and make the caller's higher-priority request
disappear.
- `purgeMediaCache()` returns `nil, message` instead of a bare `false`,
a media URL whose scheme is not http(s) now raises `sysDownloadError`
instead of being refused silently, and the last pass of a `loops = N`
track can no longer have its ending swallowed.
#### Motivation for adding to Mudlet
"Pause the ambience, start the combat theme" is an ordinary GMCP/script
sequence and in 5.0 it produced silence plus a misleading event.
#### Other info (issues closed, discussion etc)
Found by the 5.0 QA sweep (finding C16). The three commits behind it
were each reviewed alone and share one state machine: `92f01b850` "fix:
Client.Media loops=-1 plays once (#9569)", `3474cb58d` "fix: playing a
sound again right after stopping it (#9611)" and `8dd99e4db` "fix: media
that fails to load never reports it (#9612)".
Pause `a.wav`, then play `b.wav`, on `origin/development` and on this
branch:
```
before sysMediaFinished file=a.wav key=k2 tag=t2 <- old file, new request's key
(nothing playing, the paused track is gone too)
after sysMediaFinished file=a.wav key=k1 tag=t1
sysMediaStarted file=b.wav key=k2 tag=t2 <- b.wav plays
```
Contract change worth a changelog line: `purgeMediaCache()` returned
`false` on failure and now returns `nil` plus a message. `if not
purgeMediaCache()` still behaves the same; `== false` no longer matches.
Adjacent and deliberately left alone: `stopSounds{fadeaway = true}`
issued while a track is still loading is lost, and the track then plays
forever. That is byte-identical in 4.22.0, so it is not a 5.0
regression. `downloadFile()`'s five other refusals (path traversal,
three directory-creation failures, an invalid URL) are still silent,
also pre-existing.
**Test case:** `playSoundFile{name = "a.wav", key = "k1"}`,
`pauseSounds()`, then `playSoundFile{name = "b.wav", key = "k2"}` -
b.wav plays and exactly one `sysMediaFinished` arrives, naming a.wav and
k1. Seven new specs in `Media_spec.lua` (six of them fail against
`origin/development`) and one new case in `TMediaLoopTest` cover this,
pause-then-play for music, a refused-on-priority request, re-entrant
`play()`, `loops = 2`, and both failure contracts.
Assisted-by: Claude:claude-opus-5
2026-08-07 06:20:28 +02:00
// Staged rather than played: only a backend that delivers EndOfMedia before StoppedState
// announces a pass from inside continuePlaying()'s setSource(), and no runner this suite has
// does. sourceChanged stands in for that announcement, being emitted from inside the same
// setSource() call.
void test_continuingToTheNextPassClearsTheEarlierAnnouncement ( )
{
const QString path = qsl ( " %1/pass.wav " ) . arg ( mProbeDir . path ( ) ) ;
QFile file ( path ) ;
QVERIFY ( file . open ( QIODevice : : WriteOnly ) ) ;
file . write ( wavBytes ( ) ) ;
file . close ( ) ;
TMediaData data { } ;
infrastructure: a media player no longer announces its own destruction (#9746)
#### Brief overview of PR changes/additions
- `TMediaPlayer::~TMediaPlayer()` blocks its `QMediaPlayer`'s signals
before the `stop()`/`setSource(QUrl())` that unloads the media, so no
handler is called with a player whose members are going away. The unload
itself is unchanged, and Qt still emits `destroyed()`.
- New `TMediaLoopTest::test_destroyingAPlayerAnnouncesNothing()`, with a
control unload on a player that is not being destroyed so it cannot pass
vacuously.
- `test_continuingToTheNextPassClearsTheEarlierAnnouncement()` declares
its flag ahead of the player, so a lambda connected to that player
always has live stack to write to.
#### Motivation for adding to Mudlet
Emitting signals from a destructor is a landmine for any connected code:
today TMedia's own handlers survive it only because each one locks an
already-expired `weak_ptr`, and the test suite, which does not, aborted.
#### Other info (issues closed, discussion etc)
Closes #9740 "TMediaLoopTest aborts under AddressSanitizer with
stack-use-after-scope".
Reproduced and verified on Linux with a clang ASan Debug build
(`USE_SANITIZER=address`): pre-fix `TMediaLoopTest` aborts with
`stack-use-after-scope` in `~TMediaPlayer` ->
`QMediaPlayer::setSource()`, post-fix the suite is clean. The new test
fails without the destructor change (counts 1 announcement) and passes
with it. GCC does not poison per-variable within a scope, so the abort
only shows up under clang/AppleClang.
**Test case:** `cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug
-DCMAKE_CXX_COMPILER=clang++` then `cmake --build build --target
TMediaLoopTest && ctest --test-dir build -R TMediaLoopTest` - passes
instead of `***Exception`.
Assisted-by: Claude:claude-opus-5
2026-08-10 22:17:53 +02:00
// Declared ahead of the player: should a player ever emit while being destroyed again,
// the lambda below has to have somewhere live to write to.
bool announcedFromInsideSetSource = false ;
fix: pausing a track and then playing a different one left you with silence (#9710)
#### Brief overview of PR changes/additions
- Ending a track is now done before its player is handed to the next
one, so pausing a sound (or music) and playing a *different* file plays
it, and reports the paused track's own ending rather than a spurious
`sysMediaFinished` carrying the new request's key and tag.
- A `play()` call now owns the player it is setting up, so a
`sysMediaFinished` handler that starts media of its own can no longer
take that player over and make the caller's higher-priority request
disappear.
- `purgeMediaCache()` returns `nil, message` instead of a bare `false`,
a media URL whose scheme is not http(s) now raises `sysDownloadError`
instead of being refused silently, and the last pass of a `loops = N`
track can no longer have its ending swallowed.
#### Motivation for adding to Mudlet
"Pause the ambience, start the combat theme" is an ordinary GMCP/script
sequence and in 5.0 it produced silence plus a misleading event.
#### Other info (issues closed, discussion etc)
Found by the 5.0 QA sweep (finding C16). The three commits behind it
were each reviewed alone and share one state machine: `92f01b850` "fix:
Client.Media loops=-1 plays once (#9569)", `3474cb58d` "fix: playing a
sound again right after stopping it (#9611)" and `8dd99e4db` "fix: media
that fails to load never reports it (#9612)".
Pause `a.wav`, then play `b.wav`, on `origin/development` and on this
branch:
```
before sysMediaFinished file=a.wav key=k2 tag=t2 <- old file, new request's key
(nothing playing, the paused track is gone too)
after sysMediaFinished file=a.wav key=k1 tag=t1
sysMediaStarted file=b.wav key=k2 tag=t2 <- b.wav plays
```
Contract change worth a changelog line: `purgeMediaCache()` returned
`false` on failure and now returns `nil` plus a message. `if not
purgeMediaCache()` still behaves the same; `== false` no longer matches.
Adjacent and deliberately left alone: `stopSounds{fadeaway = true}`
issued while a track is still loading is lost, and the track then plays
forever. That is byte-identical in 4.22.0, so it is not a 5.0
regression. `downloadFile()`'s five other refusals (path traversal,
three directory-creation failures, an invalid URL) are still silent,
also pre-existing.
**Test case:** `playSoundFile{name = "a.wav", key = "k1"}`,
`pauseSounds()`, then `playSoundFile{name = "b.wav", key = "k2"}` -
b.wav plays and exactly one `sysMediaFinished` arrives, naming a.wav and
k1. Seven new specs in `Media_spec.lua` (six of them fail against
`origin/development`) and one new case in `TMediaLoopTest` cover this,
pause-then-play for music, a refused-on-priority request, re-entrant
`play()`, `loops = 2`, and both failure contracts.
Assisted-by: Claude:claude-opus-5
2026-08-07 06:20:28 +02:00
TMediaPlayer player ( nullptr , data ) ;
QVERIFY ( player . isInitialized ( ) ) ;
connect ( player . mediaPlayer ( ) , & QMediaPlayer : : sourceChanged , player . mediaPlayer ( ) , [ & ] ( const QUrl & ) {
player . noteEndAnnounced ( ) ;
announcedFromInsideSetSource = true ;
} ) ;
player . continuePlaying ( QUrl : : fromLocalFile ( path ) ) ;
player . mediaPlayer ( ) - > stop ( ) ;
QVERIFY2 ( announcedFromInsideSetSource , " setSource() emitted nothing synchronously, so the ordering this test is about was never staged. " ) ;
QVERIFY2 ( ! player . endAnnounced ( ) , " The new pass started already counted as announced, so its own ending would be swallowed as a duplicate. " ) ;
}
infrastructure: a media player no longer announces its own destruction (#9746)
#### Brief overview of PR changes/additions
- `TMediaPlayer::~TMediaPlayer()` blocks its `QMediaPlayer`'s signals
before the `stop()`/`setSource(QUrl())` that unloads the media, so no
handler is called with a player whose members are going away. The unload
itself is unchanged, and Qt still emits `destroyed()`.
- New `TMediaLoopTest::test_destroyingAPlayerAnnouncesNothing()`, with a
control unload on a player that is not being destroyed so it cannot pass
vacuously.
- `test_continuingToTheNextPassClearsTheEarlierAnnouncement()` declares
its flag ahead of the player, so a lambda connected to that player
always has live stack to write to.
#### Motivation for adding to Mudlet
Emitting signals from a destructor is a landmine for any connected code:
today TMedia's own handlers survive it only because each one locks an
already-expired `weak_ptr`, and the test suite, which does not, aborted.
#### Other info (issues closed, discussion etc)
Closes #9740 "TMediaLoopTest aborts under AddressSanitizer with
stack-use-after-scope".
Reproduced and verified on Linux with a clang ASan Debug build
(`USE_SANITIZER=address`): pre-fix `TMediaLoopTest` aborts with
`stack-use-after-scope` in `~TMediaPlayer` ->
`QMediaPlayer::setSource()`, post-fix the suite is clean. The new test
fails without the destructor change (counts 1 announcement) and passes
with it. GCC does not poison per-variable within a scope, so the abort
only shows up under clang/AppleClang.
**Test case:** `cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug
-DCMAKE_CXX_COMPILER=clang++` then `cmake --build build --target
TMediaLoopTest && ctest --test-dir build -R TMediaLoopTest` - passes
instead of `***Exception`.
Assisted-by: Claude:claude-opus-5
2026-08-10 22:17:53 +02:00
// Destroying a player unloads whatever it still holds, and nothing may hear that: a handler
// that does would be reading a TMediaPlayer whose members are about to go. The handlers
// TMedia installs decline anyway, each by way of a weak_ptr already expired by then, so
// what this holds to is that no future one has to. Staged rather than played, since the
// unload needs a source and not a backend that can decode it (#9740).
void test_destroyingAPlayerAnnouncesNothing ( )
{
const QString path = qsl ( " %1/teardown.wav " ) . arg ( mProbeDir . path ( ) ) ;
QFile file ( path ) ;
QVERIFY ( file . open ( QIODevice : : WriteOnly ) ) ;
file . write ( wavBytes ( ) ) ;
file . close ( ) ;
int announcementsWhileBeingDestroyed = 0 ;
bool beingDestroyed = false ;
const auto count = [ & ] ( ) {
if ( beingDestroyed ) {
+ + announcementsWhileBeingDestroyed ;
}
} ;
TMediaData data { } ;
{
TMediaPlayer player ( nullptr , data ) ;
QVERIFY ( player . isInitialized ( ) ) ;
connect ( player . mediaPlayer ( ) , & QMediaPlayer : : sourceChanged , player . mediaPlayer ( ) , count ) ;
connect ( player . mediaPlayer ( ) , & QMediaPlayer : : playbackStateChanged , player . mediaPlayer ( ) , count ) ;
player . continuePlaying ( QUrl : : fromLocalFile ( path ) ) ;
QVERIFY2 ( ! player . mediaPlayer ( ) - > source ( ) . isEmpty ( ) , " The player holds no source, so its destructor has nothing to unload and this test proves nothing. " ) ;
beingDestroyed = true ;
}
QVERIFY2 ( announcementsWhileBeingDestroyed = = 0 , " A player announced its own teardown, so every handler connected to it ran against a player being deleted. " ) ;
// The same unload on a player that is not being destroyed, to keep the assertion above
// from passing on a Qt that has stopped announcing unloads at all.
int announcementsFromAnUnblockedUnload = 0 ;
const auto countUnblocked = [ & ] ( ) {
+ + announcementsFromAnUnblockedUnload ;
} ;
QMediaPlayer unblocked ;
connect ( & unblocked , & QMediaPlayer : : sourceChanged , & unblocked , countUnblocked ) ;
connect ( & unblocked , & QMediaPlayer : : playbackStateChanged , & unblocked , countUnblocked ) ;
unblocked . setSource ( QUrl : : fromLocalFile ( path ) ) ;
announcementsFromAnUnblockedUnload = 0 ;
unblocked . stop ( ) ;
unblocked . setSource ( QUrl ( ) ) ;
QVERIFY2 ( announcementsFromAnUnblockedUnload > 0 , " An unload announced nothing even unblocked, so the assertion above passes without the destructor having to block anything. " ) ;
}
Fix: Sounds going silent when a file fails to load (#9612)
#### Brief overview of PR changes/additions
Follow-up hardening on top of #9569. Two ways a media player could end
up silent while still holding its source, plus the crash and the test
gaps found chasing them.
**A track that fails to load.** Nothing in `TMedia` listened for
`QMediaPlayer::errorOccurred`. A player that was already stopped when
its source failed reports no playback state change — and that signal is
what ends a playback, releases the source and raises `sysMediaFinished`.
The track fell silent holding a file nothing would ever release. The
error is now acted on.
**A track stopped while it is still loading.** Qt already considers an
unstarted player stopped, so `stop()` draws no state change out of one
mid-load, with the same result. This is not a narrow race: on an
asynchronous backend the Linux and Windows runners hit it every time.
`stopMedia()` now ends such a playback itself instead of waiting for a
report that is never coming.
Around those:
- The deferred source release is a single function shared by the stop,
error and teardown paths. Whether the player's own state gets a say
differs between them, so callers pass a `PlaybackEnd`: a stop needs it,
because a restart may be in flight and a player loading its next source
looks identical to a stopped one; a failure must *not* have it, because
a backend can report `PlayingState` for media it has just failed to
load.
- Re-sourcing a player goes through `claimSource()`, `continuePlaying()`
and `releaseSource()`, so the generation bump that tells a pending
release the track has moved on cannot be forgotten at a call site. A
missed bump is what let an earlier revision of #9569 clear the source of
a track that had just been restarted.
- `stopMedia()` empties the playlist, so an explicit stop cannot leave a
loop armed to restart itself from the `EndOfMedia` handler.
- `setupVideo()` failing now releases the source it claimed, and hides
the video widget only under the same `mediaWidget`/`mediaClose` guards
the deferred release uses — previously it could hide a label belonging
to an earlier clip.
- `src/dlgTriggerEditor.cpp` is here for one guard:
`runScheduledCleanReset()` repopulates itself from a `Host` that a
profile teardown has already destroyed. Unrelated to media, but it
crashed the media tests once they started running.
Behaviour worth knowing about when reviewing:
- `sysMediaFinished` now fires for a failed load and for a stop issued
mid-load, where nothing fired before. It is suppressed when the source
has already been released, where it would only have carried an empty
file name and path.
- `purgeMediaCache()` returns `false` when the directory could not be
fully removed, instead of always returning `true`.
- The closing closed caption is suppressed between the passes of a
looping track, and printed by `stopAllMediaPlayers()`, which releases
synchronously.
- `TMedia` gains three read-only diagnostics used by the tests —
`playersHoldingSource()`, `mediaPlayerCount()` and
`playersInPlayingState()`. A deferred release is otherwise unobservable:
`playingMedia()` has already dropped the player, the caption needs
captions enabled, and the video signal needs a widget.
Tests: new slots for a finite `loops=N` track, an explicit stop, an
unplayable source and a reused player. `probeBackend()` measures what
the backend can demonstrate — whether it starts playback at all, decodes
to `EndOfMedia`, orders `EndOfMedia` against `StoppedState`, starts
synchronously, and reports an undecodable file — and each test skips on
the capabilities it needs, printing what was measured.
`QT_MEDIA_BACKEND` is pinned to whatever `main.cpp` ships per platform,
since `QTEST_MAIN` does not run `main.cpp` and the tests were otherwise
exercising Qt's default backend rather than the one users get.
#### Motivation for adding to Mudlet
Both silent-failure cases are real and user-visible. A game sending a
filename Qt cannot decode, or a file gone from the media cache, would
kill the sound and leak the player's source while reporting nothing; and
`stopMusic()` shortly after `playMusic()` would leak the source every
time on Windows and Linux. A script chaining tracks off
`sysMediaFinished` waited forever in both cases.
They share a root cause with #9566: the deferred release could not tell
what the player was doing when its turn came around. The
`claimSource()`/`continuePlaying()`/`releaseSource()` encapsulation is
the durable part — it turns "remember to bump the counter" from a
convention into something the API does for you.
The test work matters as much as the fixes. The #9566 regression guard
was skipping on every CI job, so it was protecting nothing; both bugs
above were caught only once it actually ran.
#### Other info (issues closed, discussion etc)
Follow-up to #9569 / #9566. No issue number of its own.
Testing notes: the full functional suite passes — 66/66 on Linux, and
the media suite is green on the Ubuntu, Windows and both macOS jobs.
Backends differ in what they can demonstrate, so some media tests skip
by design. Under the environment ctest uses, six of the seven skip on
macOS: the `darwin` backend starts playback synchronously and delivers
`EndOfMedia` before `StoppedState`, so it can stage neither the claim
race nor the #9566 ordering, and it does not decode under
`QT_QPA_PLATFORM=offscreen`. Those paths run on the Ubuntu and Windows
FFmpeg builds, which is where both bugs in this PR were caught. Every
skip prints what the backend could not demonstrate and why, so an inert
guard is visible rather than silent.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-05 00:34:43 -04:00
// The deferred cleanup must still fire for a genuinely finished track, otherwise the
// media source release added by #9237 is lost. Releasing the source is what this asserts
// on because playingMedia() has already dropped the player by the time the cleanup runs.
Fix looping sounds playing only once (#9569)
#### Brief overview of PR changes/additions
Looping media (`loops=-1`) plays only once on Qt's FFmpeg multimedia
backend.
That backend ends a track by emitting `StoppedState` **first** and
`EndOfMedia` **second**, and it skips the `EndOfMedia` notification when
the playback engine was destroyed in between:
```cpp
if (currentPlaybackEngine)
stateChanged(QMediaPlayer::StoppedState);
if (currentPlaybackEngine)
mediaStatusChanged(QMediaPlayer::EndOfMedia);
```
Mudlet restarts a loop from its `EndOfMedia` handler, but
`TMedia::handlePlayerPlaybackStateChanged()` called `setSource(QUrl())`
immediately on `StoppedState`. That destroyed the engine, so
`EndOfMedia` never arrived and the loop died after one pass.
This defers the cleanup by one event-loop turn and re-checks the
playback state, so a loop restart can claim the player first while a
genuinely stopped player is still torn down.
#### Motivation for adding to Mudlet
Fixes #9566. Ambient/background music tracks stop after a single pass,
which affects any game using `Client.Media.Play` with `loops: -1`, MSP,
or `playMusicFile()`.
The immediate source clearing was introduced by #9237 as a
resource-cleanup measure. That cleanup is preserved here — it just runs
one event-loop turn later, and only if the player is still stopped.
#### Other info (issues closed, discussion etc)
Fixes #9566. Regression introduced by #9237 (merged 2026-04-29), so 4.21
and 4.22 are affected; 4.20.1 and earlier are not.
**Verified on Windows against the FFmpeg backend.** The bug reproduces
on unfixed code there, and this change resolves it without regressing
the cleanup that #9237 added.
It is worth being explicit that this could not be confirmed on macOS:
that platform ships `libdarwinmediaplugin`, which emits `EndOfMedia`
*before* `StoppedState` — the reverse ordering — so the loop restarts
before any cleanup runs and the bug cannot occur there at all. Reverting
the fix on macOS produced identical behaviour. The fix is therefore
specific to backends with the FFmpeg ordering, which is where the issue
was reported.
A functional test is included
(`test/functional_tests/TMediaLoopTest.cpp`) covering both halves of the
contract: a looping track must survive the stop/restart cycle, and a
one-shot track must still be cleaned up. It probes the backend before
asserting and **skips** unless that backend can both finish a clip and
emit `StoppedState` before `EndOfMedia`. Without that guard the looping
assertion passes on broken code, which is exactly what happened on
macOS. Expected results:
- FFmpeg backend → runs and asserts
- macOS darwin backend → skips (cannot exhibit the bug)
- Anywhere media stalls under `QT_QPA_PLATFORM=offscreen`, which the
functional suite forces → skips
So it may well skip in CI; that is intentional and preferable to a test
that always passes.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-02 13:11:37 -04:00
void test_oneShotTrackIsCleanedUpWhenItFinishes ( )
{
Fix: Sounds going silent when a file fails to load (#9612)
#### Brief overview of PR changes/additions
Follow-up hardening on top of #9569. Two ways a media player could end
up silent while still holding its source, plus the crash and the test
gaps found chasing them.
**A track that fails to load.** Nothing in `TMedia` listened for
`QMediaPlayer::errorOccurred`. A player that was already stopped when
its source failed reports no playback state change — and that signal is
what ends a playback, releases the source and raises `sysMediaFinished`.
The track fell silent holding a file nothing would ever release. The
error is now acted on.
**A track stopped while it is still loading.** Qt already considers an
unstarted player stopped, so `stop()` draws no state change out of one
mid-load, with the same result. This is not a narrow race: on an
asynchronous backend the Linux and Windows runners hit it every time.
`stopMedia()` now ends such a playback itself instead of waiting for a
report that is never coming.
Around those:
- The deferred source release is a single function shared by the stop,
error and teardown paths. Whether the player's own state gets a say
differs between them, so callers pass a `PlaybackEnd`: a stop needs it,
because a restart may be in flight and a player loading its next source
looks identical to a stopped one; a failure must *not* have it, because
a backend can report `PlayingState` for media it has just failed to
load.
- Re-sourcing a player goes through `claimSource()`, `continuePlaying()`
and `releaseSource()`, so the generation bump that tells a pending
release the track has moved on cannot be forgotten at a call site. A
missed bump is what let an earlier revision of #9569 clear the source of
a track that had just been restarted.
- `stopMedia()` empties the playlist, so an explicit stop cannot leave a
loop armed to restart itself from the `EndOfMedia` handler.
- `setupVideo()` failing now releases the source it claimed, and hides
the video widget only under the same `mediaWidget`/`mediaClose` guards
the deferred release uses — previously it could hide a label belonging
to an earlier clip.
- `src/dlgTriggerEditor.cpp` is here for one guard:
`runScheduledCleanReset()` repopulates itself from a `Host` that a
profile teardown has already destroyed. Unrelated to media, but it
crashed the media tests once they started running.
Behaviour worth knowing about when reviewing:
- `sysMediaFinished` now fires for a failed load and for a stop issued
mid-load, where nothing fired before. It is suppressed when the source
has already been released, where it would only have carried an empty
file name and path.
- `purgeMediaCache()` returns `false` when the directory could not be
fully removed, instead of always returning `true`.
- The closing closed caption is suppressed between the passes of a
looping track, and printed by `stopAllMediaPlayers()`, which releases
synchronously.
- `TMedia` gains three read-only diagnostics used by the tests —
`playersHoldingSource()`, `mediaPlayerCount()` and
`playersInPlayingState()`. A deferred release is otherwise unobservable:
`playingMedia()` has already dropped the player, the caption needs
captions enabled, and the video signal needs a widget.
Tests: new slots for a finite `loops=N` track, an explicit stop, an
unplayable source and a reused player. `probeBackend()` measures what
the backend can demonstrate — whether it starts playback at all, decodes
to `EndOfMedia`, orders `EndOfMedia` against `StoppedState`, starts
synchronously, and reports an undecodable file — and each test skips on
the capabilities it needs, printing what was measured.
`QT_MEDIA_BACKEND` is pinned to whatever `main.cpp` ships per platform,
since `QTEST_MAIN` does not run `main.cpp` and the tests were otherwise
exercising Qt's default backend rather than the one users get.
#### Motivation for adding to Mudlet
Both silent-failure cases are real and user-visible. A game sending a
filename Qt cannot decode, or a file gone from the media cache, would
kill the sound and leak the player's source while reporting nothing; and
`stopMusic()` shortly after `playMusic()` would leak the source every
time on Windows and Linux. A script chaining tracks off
`sysMediaFinished` waited forever in both cases.
They share a root cause with #9566: the deferred release could not tell
what the player was doing when its turn came around. The
`claimSource()`/`continuePlaying()`/`releaseSource()` encapsulation is
the durable part — it turns "remember to bump the counter" from a
convention into something the API does for you.
The test work matters as much as the fixes. The #9566 regression guard
was skipping on every CI job, so it was protecting nothing; both bugs
above were caught only once it actually ran.
#### Other info (issues closed, discussion etc)
Follow-up to #9569 / #9566. No issue number of its own.
Testing notes: the full functional suite passes — 66/66 on Linux, and
the media suite is green on the Ubuntu, Windows and both macOS jobs.
Backends differ in what they can demonstrate, so some media tests skip
by design. Under the environment ctest uses, six of the seven skip on
macOS: the `darwin` backend starts playback synchronously and delivers
`EndOfMedia` before `StoppedState`, so it can stage neither the claim
race nor the #9566 ordering, and it does not decode under
`QT_QPA_PLATFORM=offscreen`. Those paths run on the Ubuntu and Windows
FFmpeg builds, which is where both bugs in this PR were caught. Every
skip prints what the backend could not demonstrate and why, so an inert
guard is visible rather than silent.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-05 00:34:43 -04:00
SKIP_OR_FAIL_WITHOUT ( mCannotPlayReason ) ;
Fix looping sounds playing only once (#9569)
#### Brief overview of PR changes/additions
Looping media (`loops=-1`) plays only once on Qt's FFmpeg multimedia
backend.
That backend ends a track by emitting `StoppedState` **first** and
`EndOfMedia` **second**, and it skips the `EndOfMedia` notification when
the playback engine was destroyed in between:
```cpp
if (currentPlaybackEngine)
stateChanged(QMediaPlayer::StoppedState);
if (currentPlaybackEngine)
mediaStatusChanged(QMediaPlayer::EndOfMedia);
```
Mudlet restarts a loop from its `EndOfMedia` handler, but
`TMedia::handlePlayerPlaybackStateChanged()` called `setSource(QUrl())`
immediately on `StoppedState`. That destroyed the engine, so
`EndOfMedia` never arrived and the loop died after one pass.
This defers the cleanup by one event-loop turn and re-checks the
playback state, so a loop restart can claim the player first while a
genuinely stopped player is still torn down.
#### Motivation for adding to Mudlet
Fixes #9566. Ambient/background music tracks stop after a single pass,
which affects any game using `Client.Media.Play` with `loops: -1`, MSP,
or `playMusicFile()`.
The immediate source clearing was introduced by #9237 as a
resource-cleanup measure. That cleanup is preserved here — it just runs
one event-loop turn later, and only if the player is still stopped.
#### Other info (issues closed, discussion etc)
Fixes #9566. Regression introduced by #9237 (merged 2026-04-29), so 4.21
and 4.22 are affected; 4.20.1 and earlier are not.
**Verified on Windows against the FFmpeg backend.** The bug reproduces
on unfixed code there, and this change resolves it without regressing
the cleanup that #9237 added.
It is worth being explicit that this could not be confirmed on macOS:
that platform ships `libdarwinmediaplugin`, which emits `EndOfMedia`
*before* `StoppedState` — the reverse ordering — so the loop restarts
before any cleanup runs and the bug cannot occur there at all. Reverting
the fix on macOS produced identical behaviour. The fix is therefore
specific to backends with the FFmpeg ordering, which is where the issue
was reported.
A functional test is included
(`test/functional_tests/TMediaLoopTest.cpp`) covering both halves of the
contract: a looping track must survive the stop/restart cycle, and a
one-shot track must still be cleaned up. It probes the backend before
asserting and **skips** unless that backend can both finish a clip and
emit `StoppedState` before `EndOfMedia`. Without that guard the looping
assertion passes on broken code, which is exactly what happened on
macOS. Expected results:
- FFmpeg backend → runs and asserts
- macOS darwin backend → skips (cannot exhibit the bug)
- Anywhere media stalls under `QT_QPA_PLATFORM=offscreen`, which the
functional suite forces → skips
So it may well skip in CI; that is intentional and preferable to a test
that always passes.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-02 13:11:37 -04:00
auto * media = startProfileAndGetMedia ( ) ;
QVERIFY ( media ) ;
Fix: Sounds going silent when a file fails to load (#9612)
#### Brief overview of PR changes/additions
Follow-up hardening on top of #9569. Two ways a media player could end
up silent while still holding its source, plus the crash and the test
gaps found chasing them.
**A track that fails to load.** Nothing in `TMedia` listened for
`QMediaPlayer::errorOccurred`. A player that was already stopped when
its source failed reports no playback state change — and that signal is
what ends a playback, releases the source and raises `sysMediaFinished`.
The track fell silent holding a file nothing would ever release. The
error is now acted on.
**A track stopped while it is still loading.** Qt already considers an
unstarted player stopped, so `stop()` draws no state change out of one
mid-load, with the same result. This is not a narrow race: on an
asynchronous backend the Linux and Windows runners hit it every time.
`stopMedia()` now ends such a playback itself instead of waiting for a
report that is never coming.
Around those:
- The deferred source release is a single function shared by the stop,
error and teardown paths. Whether the player's own state gets a say
differs between them, so callers pass a `PlaybackEnd`: a stop needs it,
because a restart may be in flight and a player loading its next source
looks identical to a stopped one; a failure must *not* have it, because
a backend can report `PlayingState` for media it has just failed to
load.
- Re-sourcing a player goes through `claimSource()`, `continuePlaying()`
and `releaseSource()`, so the generation bump that tells a pending
release the track has moved on cannot be forgotten at a call site. A
missed bump is what let an earlier revision of #9569 clear the source of
a track that had just been restarted.
- `stopMedia()` empties the playlist, so an explicit stop cannot leave a
loop armed to restart itself from the `EndOfMedia` handler.
- `setupVideo()` failing now releases the source it claimed, and hides
the video widget only under the same `mediaWidget`/`mediaClose` guards
the deferred release uses — previously it could hide a label belonging
to an earlier clip.
- `src/dlgTriggerEditor.cpp` is here for one guard:
`runScheduledCleanReset()` repopulates itself from a `Host` that a
profile teardown has already destroyed. Unrelated to media, but it
crashed the media tests once they started running.
Behaviour worth knowing about when reviewing:
- `sysMediaFinished` now fires for a failed load and for a stop issued
mid-load, where nothing fired before. It is suppressed when the source
has already been released, where it would only have carried an empty
file name and path.
- `purgeMediaCache()` returns `false` when the directory could not be
fully removed, instead of always returning `true`.
- The closing closed caption is suppressed between the passes of a
looping track, and printed by `stopAllMediaPlayers()`, which releases
synchronously.
- `TMedia` gains three read-only diagnostics used by the tests —
`playersHoldingSource()`, `mediaPlayerCount()` and
`playersInPlayingState()`. A deferred release is otherwise unobservable:
`playingMedia()` has already dropped the player, the caption needs
captions enabled, and the video signal needs a widget.
Tests: new slots for a finite `loops=N` track, an explicit stop, an
unplayable source and a reused player. `probeBackend()` measures what
the backend can demonstrate — whether it starts playback at all, decodes
to `EndOfMedia`, orders `EndOfMedia` against `StoppedState`, starts
synchronously, and reports an undecodable file — and each test skips on
the capabilities it needs, printing what was measured.
`QT_MEDIA_BACKEND` is pinned to whatever `main.cpp` ships per platform,
since `QTEST_MAIN` does not run `main.cpp` and the tests were otherwise
exercising Qt's default backend rather than the one users get.
#### Motivation for adding to Mudlet
Both silent-failure cases are real and user-visible. A game sending a
filename Qt cannot decode, or a file gone from the media cache, would
kill the sound and leak the player's source while reporting nothing; and
`stopMusic()` shortly after `playMusic()` would leak the source every
time on Windows and Linux. A script chaining tracks off
`sysMediaFinished` waited forever in both cases.
They share a root cause with #9566: the deferred release could not tell
what the player was doing when its turn came around. The
`claimSource()`/`continuePlaying()`/`releaseSource()` encapsulation is
the durable part — it turns "remember to bump the counter" from a
convention into something the API does for you.
The test work matters as much as the fixes. The #9566 regression guard
was skipping on every CI job, so it was protecting nothing; both bugs
above were caught only once it actually ran.
#### Other info (issues closed, discussion etc)
Follow-up to #9569 / #9566. No issue number of its own.
Testing notes: the full functional suite passes — 66/66 on Linux, and
the media suite is green on the Ubuntu, Windows and both macOS jobs.
Backends differ in what they can demonstrate, so some media tests skip
by design. Under the environment ctest uses, six of the seven skip on
macOS: the `darwin` backend starts playback synchronously and delivers
`EndOfMedia` before `StoppedState`, so it can stage neither the claim
race nor the #9566 ordering, and it does not decode under
`QT_QPA_PLATFORM=offscreen`. Those paths run on the Ubuntu and Windows
FFmpeg builds, which is where both bugs in this PR were caught. Every
skip prints what the backend could not demonstrate and why, so an inert
guard is visible rather than silent.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-05 00:34:43 -04:00
watchMediaFinished ( ) ;
Fix looping sounds playing only once (#9569)
#### Brief overview of PR changes/additions
Looping media (`loops=-1`) plays only once on Qt's FFmpeg multimedia
backend.
That backend ends a track by emitting `StoppedState` **first** and
`EndOfMedia` **second**, and it skips the `EndOfMedia` notification when
the playback engine was destroyed in between:
```cpp
if (currentPlaybackEngine)
stateChanged(QMediaPlayer::StoppedState);
if (currentPlaybackEngine)
mediaStatusChanged(QMediaPlayer::EndOfMedia);
```
Mudlet restarts a loop from its `EndOfMedia` handler, but
`TMedia::handlePlayerPlaybackStateChanged()` called `setSource(QUrl())`
immediately on `StoppedState`. That destroyed the engine, so
`EndOfMedia` never arrived and the loop died after one pass.
This defers the cleanup by one event-loop turn and re-checks the
playback state, so a loop restart can claim the player first while a
genuinely stopped player is still torn down.
#### Motivation for adding to Mudlet
Fixes #9566. Ambient/background music tracks stop after a single pass,
which affects any game using `Client.Media.Play` with `loops: -1`, MSP,
or `playMusicFile()`.
The immediate source clearing was introduced by #9237 as a
resource-cleanup measure. That cleanup is preserved here — it just runs
one event-loop turn later, and only if the player is still stopped.
#### Other info (issues closed, discussion etc)
Fixes #9566. Regression introduced by #9237 (merged 2026-04-29), so 4.21
and 4.22 are affected; 4.20.1 and earlier are not.
**Verified on Windows against the FFmpeg backend.** The bug reproduces
on unfixed code there, and this change resolves it without regressing
the cleanup that #9237 added.
It is worth being explicit that this could not be confirmed on macOS:
that platform ships `libdarwinmediaplugin`, which emits `EndOfMedia`
*before* `StoppedState` — the reverse ordering — so the loop restarts
before any cleanup runs and the bug cannot occur there at all. Reverting
the fix on macOS produced identical behaviour. The fix is therefore
specific to backends with the FFmpeg ordering, which is where the issue
was reported.
A functional test is included
(`test/functional_tests/TMediaLoopTest.cpp`) covering both halves of the
contract: a looping track must survive the stop/restart cycle, and a
one-shot track must still be cleaned up. It probes the backend before
asserting and **skips** unless that backend can both finish a clip and
emit `StoppedState` before `EndOfMedia`. Without that guard the looping
assertion passes on broken code, which is exactly what happened on
macOS. Expected results:
- FFmpeg backend → runs and asserts
- macOS darwin backend → skips (cannot exhibit the bug)
- Anywhere media stalls under `QT_QPA_PLATFORM=offscreen`, which the
functional suite forces → skips
So it may well skip in CI; that is intentional and preferable to a test
that always passes.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-02 13:11:37 -04:00
const QString fileName = writeClip ( qsl ( " oneshot.wav " ) ) ;
Fix: Sounds going silent when a file fails to load (#9612)
#### Brief overview of PR changes/additions
Follow-up hardening on top of #9569. Two ways a media player could end
up silent while still holding its source, plus the crash and the test
gaps found chasing them.
**A track that fails to load.** Nothing in `TMedia` listened for
`QMediaPlayer::errorOccurred`. A player that was already stopped when
its source failed reports no playback state change — and that signal is
what ends a playback, releases the source and raises `sysMediaFinished`.
The track fell silent holding a file nothing would ever release. The
error is now acted on.
**A track stopped while it is still loading.** Qt already considers an
unstarted player stopped, so `stop()` draws no state change out of one
mid-load, with the same result. This is not a narrow race: on an
asynchronous backend the Linux and Windows runners hit it every time.
`stopMedia()` now ends such a playback itself instead of waiting for a
report that is never coming.
Around those:
- The deferred source release is a single function shared by the stop,
error and teardown paths. Whether the player's own state gets a say
differs between them, so callers pass a `PlaybackEnd`: a stop needs it,
because a restart may be in flight and a player loading its next source
looks identical to a stopped one; a failure must *not* have it, because
a backend can report `PlayingState` for media it has just failed to
load.
- Re-sourcing a player goes through `claimSource()`, `continuePlaying()`
and `releaseSource()`, so the generation bump that tells a pending
release the track has moved on cannot be forgotten at a call site. A
missed bump is what let an earlier revision of #9569 clear the source of
a track that had just been restarted.
- `stopMedia()` empties the playlist, so an explicit stop cannot leave a
loop armed to restart itself from the `EndOfMedia` handler.
- `setupVideo()` failing now releases the source it claimed, and hides
the video widget only under the same `mediaWidget`/`mediaClose` guards
the deferred release uses — previously it could hide a label belonging
to an earlier clip.
- `src/dlgTriggerEditor.cpp` is here for one guard:
`runScheduledCleanReset()` repopulates itself from a `Host` that a
profile teardown has already destroyed. Unrelated to media, but it
crashed the media tests once they started running.
Behaviour worth knowing about when reviewing:
- `sysMediaFinished` now fires for a failed load and for a stop issued
mid-load, where nothing fired before. It is suppressed when the source
has already been released, where it would only have carried an empty
file name and path.
- `purgeMediaCache()` returns `false` when the directory could not be
fully removed, instead of always returning `true`.
- The closing closed caption is suppressed between the passes of a
looping track, and printed by `stopAllMediaPlayers()`, which releases
synchronously.
- `TMedia` gains three read-only diagnostics used by the tests —
`playersHoldingSource()`, `mediaPlayerCount()` and
`playersInPlayingState()`. A deferred release is otherwise unobservable:
`playingMedia()` has already dropped the player, the caption needs
captions enabled, and the video signal needs a widget.
Tests: new slots for a finite `loops=N` track, an explicit stop, an
unplayable source and a reused player. `probeBackend()` measures what
the backend can demonstrate — whether it starts playback at all, decodes
to `EndOfMedia`, orders `EndOfMedia` against `StoppedState`, starts
synchronously, and reports an undecodable file — and each test skips on
the capabilities it needs, printing what was measured.
`QT_MEDIA_BACKEND` is pinned to whatever `main.cpp` ships per platform,
since `QTEST_MAIN` does not run `main.cpp` and the tests were otherwise
exercising Qt's default backend rather than the one users get.
#### Motivation for adding to Mudlet
Both silent-failure cases are real and user-visible. A game sending a
filename Qt cannot decode, or a file gone from the media cache, would
kill the sound and leak the player's source while reporting nothing; and
`stopMusic()` shortly after `playMusic()` would leak the source every
time on Windows and Linux. A script chaining tracks off
`sysMediaFinished` waited forever in both cases.
They share a root cause with #9566: the deferred release could not tell
what the player was doing when its turn came around. The
`claimSource()`/`continuePlaying()`/`releaseSource()` encapsulation is
the durable part — it turns "remember to bump the counter" from a
convention into something the API does for you.
The test work matters as much as the fixes. The #9566 regression guard
was skipping on every CI job, so it was protecting nothing; both bugs
above were caught only once it actually ran.
#### Other info (issues closed, discussion etc)
Follow-up to #9569 / #9566. No issue number of its own.
Testing notes: the full functional suite passes — 66/66 on Linux, and
the media suite is green on the Ubuntu, Windows and both macOS jobs.
Backends differ in what they can demonstrate, so some media tests skip
by design. Under the environment ctest uses, six of the seven skip on
macOS: the `darwin` backend starts playback synchronously and delivers
`EndOfMedia` before `StoppedState`, so it can stage neither the claim
race nor the #9566 ordering, and it does not decode under
`QT_QPA_PLATFORM=offscreen`. Those paths run on the Ubuntu and Windows
FFmpeg builds, which is where both bugs in this PR were caught. Every
skip prints what the backend could not demonstrate and why, so an inert
guard is visible rather than silent.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-05 00:34:43 -04:00
QVERIFY ( ! fileName . isEmpty ( ) ) ;
Fix looping sounds playing only once (#9569)
#### Brief overview of PR changes/additions
Looping media (`loops=-1`) plays only once on Qt's FFmpeg multimedia
backend.
That backend ends a track by emitting `StoppedState` **first** and
`EndOfMedia` **second**, and it skips the `EndOfMedia` notification when
the playback engine was destroyed in between:
```cpp
if (currentPlaybackEngine)
stateChanged(QMediaPlayer::StoppedState);
if (currentPlaybackEngine)
mediaStatusChanged(QMediaPlayer::EndOfMedia);
```
Mudlet restarts a loop from its `EndOfMedia` handler, but
`TMedia::handlePlayerPlaybackStateChanged()` called `setSource(QUrl())`
immediately on `StoppedState`. That destroyed the engine, so
`EndOfMedia` never arrived and the loop died after one pass.
This defers the cleanup by one event-loop turn and re-checks the
playback state, so a loop restart can claim the player first while a
genuinely stopped player is still torn down.
#### Motivation for adding to Mudlet
Fixes #9566. Ambient/background music tracks stop after a single pass,
which affects any game using `Client.Media.Play` with `loops: -1`, MSP,
or `playMusicFile()`.
The immediate source clearing was introduced by #9237 as a
resource-cleanup measure. That cleanup is preserved here — it just runs
one event-loop turn later, and only if the player is still stopped.
#### Other info (issues closed, discussion etc)
Fixes #9566. Regression introduced by #9237 (merged 2026-04-29), so 4.21
and 4.22 are affected; 4.20.1 and earlier are not.
**Verified on Windows against the FFmpeg backend.** The bug reproduces
on unfixed code there, and this change resolves it without regressing
the cleanup that #9237 added.
It is worth being explicit that this could not be confirmed on macOS:
that platform ships `libdarwinmediaplugin`, which emits `EndOfMedia`
*before* `StoppedState` — the reverse ordering — so the loop restarts
before any cleanup runs and the bug cannot occur there at all. Reverting
the fix on macOS produced identical behaviour. The fix is therefore
specific to backends with the FFmpeg ordering, which is where the issue
was reported.
A functional test is included
(`test/functional_tests/TMediaLoopTest.cpp`) covering both halves of the
contract: a looping track must survive the stop/restart cycle, and a
one-shot track must still be cleaned up. It probes the backend before
asserting and **skips** unless that backend can both finish a clip and
emit `StoppedState` before `EndOfMedia`. Without that guard the looping
assertion passes on broken code, which is exactly what happened on
macOS. Expected results:
- FFmpeg backend → runs and asserts
- macOS darwin backend → skips (cannot exhibit the bug)
- Anywhere media stalls under `QT_QPA_PLATFORM=offscreen`, which the
functional suite forces → skips
So it may well skip in CI; that is intentional and preferable to a test
that always passes.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-02 13:11:37 -04:00
TMediaData data = clipData ( fileName ) ;
data . setMediaLoops ( TMediaData : : MediaLoopsDefault ) ;
media - > playMedia ( data ) ;
QVERIFY2 ( waitForPlaying ( media , fileName ) , " The one-shot track never started playing. " ) ;
const bool cleanedUp = QTest : : qWaitFor (
[ & ] ( ) {
return ! playing ( media , fileName ) & & media - > playersHoldingSource ( ) = = 0 ;
} ,
QDeadlineTimer ( 10 s ) ) ;
QVERIFY2 ( cleanedUp , " A finished one-shot track never released its media source - the deferred cleanup did not run. " ) ;
Fix: Sounds going silent when a file fails to load (#9612)
#### Brief overview of PR changes/additions
Follow-up hardening on top of #9569. Two ways a media player could end
up silent while still holding its source, plus the crash and the test
gaps found chasing them.
**A track that fails to load.** Nothing in `TMedia` listened for
`QMediaPlayer::errorOccurred`. A player that was already stopped when
its source failed reports no playback state change — and that signal is
what ends a playback, releases the source and raises `sysMediaFinished`.
The track fell silent holding a file nothing would ever release. The
error is now acted on.
**A track stopped while it is still loading.** Qt already considers an
unstarted player stopped, so `stop()` draws no state change out of one
mid-load, with the same result. This is not a narrow race: on an
asynchronous backend the Linux and Windows runners hit it every time.
`stopMedia()` now ends such a playback itself instead of waiting for a
report that is never coming.
Around those:
- The deferred source release is a single function shared by the stop,
error and teardown paths. Whether the player's own state gets a say
differs between them, so callers pass a `PlaybackEnd`: a stop needs it,
because a restart may be in flight and a player loading its next source
looks identical to a stopped one; a failure must *not* have it, because
a backend can report `PlayingState` for media it has just failed to
load.
- Re-sourcing a player goes through `claimSource()`, `continuePlaying()`
and `releaseSource()`, so the generation bump that tells a pending
release the track has moved on cannot be forgotten at a call site. A
missed bump is what let an earlier revision of #9569 clear the source of
a track that had just been restarted.
- `stopMedia()` empties the playlist, so an explicit stop cannot leave a
loop armed to restart itself from the `EndOfMedia` handler.
- `setupVideo()` failing now releases the source it claimed, and hides
the video widget only under the same `mediaWidget`/`mediaClose` guards
the deferred release uses — previously it could hide a label belonging
to an earlier clip.
- `src/dlgTriggerEditor.cpp` is here for one guard:
`runScheduledCleanReset()` repopulates itself from a `Host` that a
profile teardown has already destroyed. Unrelated to media, but it
crashed the media tests once they started running.
Behaviour worth knowing about when reviewing:
- `sysMediaFinished` now fires for a failed load and for a stop issued
mid-load, where nothing fired before. It is suppressed when the source
has already been released, where it would only have carried an empty
file name and path.
- `purgeMediaCache()` returns `false` when the directory could not be
fully removed, instead of always returning `true`.
- The closing closed caption is suppressed between the passes of a
looping track, and printed by `stopAllMediaPlayers()`, which releases
synchronously.
- `TMedia` gains three read-only diagnostics used by the tests —
`playersHoldingSource()`, `mediaPlayerCount()` and
`playersInPlayingState()`. A deferred release is otherwise unobservable:
`playingMedia()` has already dropped the player, the caption needs
captions enabled, and the video signal needs a widget.
Tests: new slots for a finite `loops=N` track, an explicit stop, an
unplayable source and a reused player. `probeBackend()` measures what
the backend can demonstrate — whether it starts playback at all, decodes
to `EndOfMedia`, orders `EndOfMedia` against `StoppedState`, starts
synchronously, and reports an undecodable file — and each test skips on
the capabilities it needs, printing what was measured.
`QT_MEDIA_BACKEND` is pinned to whatever `main.cpp` ships per platform,
since `QTEST_MAIN` does not run `main.cpp` and the tests were otherwise
exercising Qt's default backend rather than the one users get.
#### Motivation for adding to Mudlet
Both silent-failure cases are real and user-visible. A game sending a
filename Qt cannot decode, or a file gone from the media cache, would
kill the sound and leak the player's source while reporting nothing; and
`stopMusic()` shortly after `playMusic()` would leak the source every
time on Windows and Linux. A script chaining tracks off
`sysMediaFinished` waited forever in both cases.
They share a root cause with #9566: the deferred release could not tell
what the player was doing when its turn came around. The
`claimSource()`/`continuePlaying()`/`releaseSource()` encapsulation is
the durable part — it turns "remember to bump the counter" from a
convention into something the API does for you.
The test work matters as much as the fixes. The #9566 regression guard
was skipping on every CI job, so it was protecting nothing; both bugs
above were caught only once it actually ran.
#### Other info (issues closed, discussion etc)
Follow-up to #9569 / #9566. No issue number of its own.
Testing notes: the full functional suite passes — 66/66 on Linux, and
the media suite is green on the Ubuntu, Windows and both macOS jobs.
Backends differ in what they can demonstrate, so some media tests skip
by design. Under the environment ctest uses, six of the seven skip on
macOS: the `darwin` backend starts playback synchronously and delivers
`EndOfMedia` before `StoppedState`, so it can stage neither the claim
race nor the #9566 ordering, and it does not decode under
`QT_QPA_PLATFORM=offscreen`. Those paths run on the Ubuntu and Windows
FFmpeg builds, which is where both bugs in this PR were caught. Every
skip prints what the backend could not demonstrate and why, so an inert
guard is visible rather than silent.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-05 00:34:43 -04:00
QVERIFY2 ( waitForMediaFinishedCount ( 1 ) , " A finished one-shot track raised no single sysMediaFinished - a script chaining its next track off that event would wait forever. " ) ;
QVERIFY2 ( mediaFinishedHolds ( qsl ( " mediaFinishedNames[1] == 'oneshot.wav' " ) ) , " sysMediaFinished named the wrong file for a finished one-shot track. " ) ;
}
// The same obligation as above, reached by an explicit stop rather than by the clip
// ending. It asks the least of the backend of any test here - only that playback starts -
// so it is the one that still runs on a runner whose backend cannot decode a clip.
//
// Deliberately the weaker waitForPlaying(): on an asynchronous backend that lands the stop
// while the track is still loading, which is a player Qt already considers stopped and so
// one that reports no state change to end its playback. Holding a source for good is
// exactly what that used to cost, so this is the case worth keeping.
void test_stoppedTrackReleasesItsSource ( )
{
SKIP_OR_FAIL_WITHOUT ( mCannotStartReason ) ;
auto * media = startProfileAndGetMedia ( ) ;
QVERIFY ( media ) ;
watchMediaFinished ( ) ;
const QString fileName = writeClip ( qsl ( " stopped.wav " ) ) ;
QVERIFY ( ! fileName . isEmpty ( ) ) ;
TMediaData data = clipData ( fileName ) ;
data . setMediaLoops ( TMediaData : : MediaLoopsRepeat ) ;
media - > playMedia ( data ) ;
QVERIFY2 ( waitForPlaying ( media , fileName ) , " The track never started playing. " ) ;
TMediaData stop = clipData ( fileName ) ;
media - > stopMedia ( stop ) ;
// Asserted separately from the release below: "still playing" and "still holding a
// source" are different faults with different causes, and a combined wait cannot say
// which of them a failure is.
const bool stopped = QTest : : qWaitFor (
[ & ] ( ) {
return ! playing ( media , fileName ) ;
} ,
QDeadlineTimer ( 10 s ) ) ;
QVERIFY2 ( stopped , " A stopped track was still reported as playing - stopMedia() did not take it out of the playing set. " ) ;
const bool released = QTest : : qWaitFor (
[ & ] ( ) {
return media - > playersHoldingSource ( ) = = 0 ;
} ,
QDeadlineTimer ( 10 s ) ) ;
QVERIFY2 ( released , " A stopped track never released its media source - the deferred release did not run. " ) ;
QVERIFY2 ( waitForMediaFinishedCount ( 1 ) , " A stopped track raised no single sysMediaFinished - the silent stop this test exists for is only half fixed if the release happens without it. " ) ;
QVERIFY2 ( mediaFinishedHolds ( qsl ( " mediaFinishedNames[1] == 'stopped.wav' " ) ) , " sysMediaFinished named the wrong file for a stopped track. " ) ;
// Nothing more may be said about it afterwards. The source outlives the event by a
// turn, so the stop, the error handler and a StoppedState report can each still find a
// playback that looks live and announce the same ending over again.
QTest : : qWait ( clipMs ) ;
QVERIFY2 ( mediaFinishedHolds ( qsl ( " mediaFinishedCount == 1 " ) ) , " A stopped track raised sysMediaFinished more than once for the same playback. " ) ;
}
// Two ways a stop can say something it should not. A bare stopMusic() matches every player
// there is, including pooled ones between tracks that have nothing playing to end; and a
// stop issued from inside a sysMediaFinished handler - the natural place for a script to
// decide it has heard enough - lands on a player still holding the source of the track it
// was just told about, which used to look exactly like one more playback to end. That
// announced again, re-entered the same handler, and recursed until the stack gave out.
void test_stopDoesNotAnnounceWhatIsNotPlaying ( )
{
SKIP_OR_FAIL_WITHOUT ( mCannotStartReason ) ;
auto * media = startProfileAndGetMedia ( ) ;
QVERIFY ( media ) ;
watchMediaFinished ( qsl ( " stopMusic() " ) ) ;
const QString fileName = writeClip ( qsl ( " recursion.wav " ) ) ;
QVERIFY ( ! fileName . isEmpty ( ) ) ;
TMediaData data = clipData ( fileName ) ;
data . setMediaLoops ( TMediaData : : MediaLoopsRepeat ) ;
media - > playMedia ( data ) ;
QVERIFY2 ( waitForPlaying ( media , fileName ) , " The track never started playing. " ) ;
TMediaData stop = clipData ( fileName ) ;
media - > stopMedia ( stop ) ;
QVERIFY2 ( waitForMediaFinishedCount ( 1 ) , " A stopped track raised no single sysMediaFinished, so the handler that stops it again never ran and the recursion this test guards was never staged. " ) ;
// Everything is idle by now, so a stop matching every player has nothing left to end.
TMediaData stopEverything ;
stopEverything . setMediaProtocol ( TMediaData : : MediaProtocolAPI ) ;
media - > stopMedia ( stopEverything ) ;
QTest : : qWait ( clipMs ) ;
QVERIFY2 ( mediaFinishedHolds ( qsl ( " mediaFinishedCount == 1 " ) ) ,
" A stop announced a playback that was already over - either the handler's own stopMusic() recursed back through it, or pooled players holding nothing were ended too. " ) ;
}
// A source that fails to load reports an error and no playback state change, because a
// player that was already stopped - as every claimSource() on a new or finished player
// leaves it, and as a loop restart or playlist advance finds it - has nothing to change
// from. Without the error being acted on, the track falls silent still holding a source
// nothing will ever release.
void test_unplayableTrackReleasesItsSource ( )
{
SKIP_OR_FAIL_WITHOUT ( mCannotPlayReason ) ;
SKIP_OR_FAIL_WITHOUT ( mNoLoadErrorReason ) ;
auto * media = startProfileAndGetMedia ( ) ;
QVERIFY ( media ) ;
watchMediaFinished ( ) ;
const QString fileName = writeUnplayableClip ( qsl ( " broken.wav " ) ) ;
QVERIFY ( ! fileName . isEmpty ( ) ) ;
TMediaData data = clipData ( fileName ) ;
data . setMediaLoops ( TMediaData : : MediaLoopsRepeat ) ;
media - > playMedia ( data ) ;
// Without this the wait below is satisfied at once by a play() that bailed out early,
// and the error path this test exists for is never reached. claimSource() sets the
// source inside playMedia() and the release is deferred, so the count is settled here.
QCOMPARE ( media - > playersHoldingSource ( ) , 1 ) ;
const bool released = QTest : : qWaitFor (
[ & ] ( ) {
return media - > playersHoldingSource ( ) = = 0 ;
} ,
QDeadlineTimer ( 10 s ) ) ;
QVERIFY2 ( released , " A track that could not be decoded held on to its media source - the playback error was never acted on. " ) ;
QVERIFY2 ( waitForMediaFinishedCount ( 1 ) , " A track that could not be decoded raised no single sysMediaFinished - a script chaining its next track off that event would wait forever. " ) ;
QVERIFY2 ( mediaFinishedHolds ( qsl ( " mediaFinishedNames[1] == 'broken.wav' " ) ) , " sysMediaFinished named the wrong file for a track that could not be decoded. " ) ;
// The error and the StoppedState that can follow it are two reports of one failure.
QTest : : qWait ( clipMs ) ;
QVERIFY2 ( mediaFinishedHolds ( qsl ( " mediaFinishedCount == 1 " ) ) , " A track that could not be decoded raised sysMediaFinished more than once for the same failure. " ) ;
Fix looping sounds playing only once (#9569)
#### Brief overview of PR changes/additions
Looping media (`loops=-1`) plays only once on Qt's FFmpeg multimedia
backend.
That backend ends a track by emitting `StoppedState` **first** and
`EndOfMedia` **second**, and it skips the `EndOfMedia` notification when
the playback engine was destroyed in between:
```cpp
if (currentPlaybackEngine)
stateChanged(QMediaPlayer::StoppedState);
if (currentPlaybackEngine)
mediaStatusChanged(QMediaPlayer::EndOfMedia);
```
Mudlet restarts a loop from its `EndOfMedia` handler, but
`TMedia::handlePlayerPlaybackStateChanged()` called `setSource(QUrl())`
immediately on `StoppedState`. That destroyed the engine, so
`EndOfMedia` never arrived and the loop died after one pass.
This defers the cleanup by one event-loop turn and re-checks the
playback state, so a loop restart can claim the player first while a
genuinely stopped player is still torn down.
#### Motivation for adding to Mudlet
Fixes #9566. Ambient/background music tracks stop after a single pass,
which affects any game using `Client.Media.Play` with `loops: -1`, MSP,
or `playMusicFile()`.
The immediate source clearing was introduced by #9237 as a
resource-cleanup measure. That cleanup is preserved here — it just runs
one event-loop turn later, and only if the player is still stopped.
#### Other info (issues closed, discussion etc)
Fixes #9566. Regression introduced by #9237 (merged 2026-04-29), so 4.21
and 4.22 are affected; 4.20.1 and earlier are not.
**Verified on Windows against the FFmpeg backend.** The bug reproduces
on unfixed code there, and this change resolves it without regressing
the cleanup that #9237 added.
It is worth being explicit that this could not be confirmed on macOS:
that platform ships `libdarwinmediaplugin`, which emits `EndOfMedia`
*before* `StoppedState` — the reverse ordering — so the loop restarts
before any cleanup runs and the bug cannot occur there at all. Reverting
the fix on macOS produced identical behaviour. The fix is therefore
specific to backends with the FFmpeg ordering, which is where the issue
was reported.
A functional test is included
(`test/functional_tests/TMediaLoopTest.cpp`) covering both halves of the
contract: a looping track must survive the stop/restart cycle, and a
one-shot track must still be cleaned up. It probes the backend before
asserting and **skips** unless that backend can both finish a clip and
emit `StoppedState` before `EndOfMedia`. Without that guard the looping
assertion passes on broken code, which is exactly what happened on
macOS. Expected results:
- FFmpeg backend → runs and asserts
- macOS darwin backend → skips (cannot exhibit the bug)
- Anywhere media stalls under `QT_QPA_PLATFORM=offscreen`, which the
functional suite forces → skips
So it may well skip in CI; that is intentional and preferable to a test
that always passes.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-02 13:11:37 -04:00
}
// A player that is handed to a different track in the same event-loop turn, as
Fix: Sounds going silent when a file fails to load (#9612)
#### Brief overview of PR changes/additions
Follow-up hardening on top of #9569. Two ways a media player could end
up silent while still holding its source, plus the crash and the test
gaps found chasing them.
**A track that fails to load.** Nothing in `TMedia` listened for
`QMediaPlayer::errorOccurred`. A player that was already stopped when
its source failed reports no playback state change — and that signal is
what ends a playback, releases the source and raises `sysMediaFinished`.
The track fell silent holding a file nothing would ever release. The
error is now acted on.
**A track stopped while it is still loading.** Qt already considers an
unstarted player stopped, so `stop()` draws no state change out of one
mid-load, with the same result. This is not a narrow race: on an
asynchronous backend the Linux and Windows runners hit it every time.
`stopMedia()` now ends such a playback itself instead of waiting for a
report that is never coming.
Around those:
- The deferred source release is a single function shared by the stop,
error and teardown paths. Whether the player's own state gets a say
differs between them, so callers pass a `PlaybackEnd`: a stop needs it,
because a restart may be in flight and a player loading its next source
looks identical to a stopped one; a failure must *not* have it, because
a backend can report `PlayingState` for media it has just failed to
load.
- Re-sourcing a player goes through `claimSource()`, `continuePlaying()`
and `releaseSource()`, so the generation bump that tells a pending
release the track has moved on cannot be forgotten at a call site. A
missed bump is what let an earlier revision of #9569 clear the source of
a track that had just been restarted.
- `stopMedia()` empties the playlist, so an explicit stop cannot leave a
loop armed to restart itself from the `EndOfMedia` handler.
- `setupVideo()` failing now releases the source it claimed, and hides
the video widget only under the same `mediaWidget`/`mediaClose` guards
the deferred release uses — previously it could hide a label belonging
to an earlier clip.
- `src/dlgTriggerEditor.cpp` is here for one guard:
`runScheduledCleanReset()` repopulates itself from a `Host` that a
profile teardown has already destroyed. Unrelated to media, but it
crashed the media tests once they started running.
Behaviour worth knowing about when reviewing:
- `sysMediaFinished` now fires for a failed load and for a stop issued
mid-load, where nothing fired before. It is suppressed when the source
has already been released, where it would only have carried an empty
file name and path.
- `purgeMediaCache()` returns `false` when the directory could not be
fully removed, instead of always returning `true`.
- The closing closed caption is suppressed between the passes of a
looping track, and printed by `stopAllMediaPlayers()`, which releases
synchronously.
- `TMedia` gains three read-only diagnostics used by the tests —
`playersHoldingSource()`, `mediaPlayerCount()` and
`playersInPlayingState()`. A deferred release is otherwise unobservable:
`playingMedia()` has already dropped the player, the caption needs
captions enabled, and the video signal needs a widget.
Tests: new slots for a finite `loops=N` track, an explicit stop, an
unplayable source and a reused player. `probeBackend()` measures what
the backend can demonstrate — whether it starts playback at all, decodes
to `EndOfMedia`, orders `EndOfMedia` against `StoppedState`, starts
synchronously, and reports an undecodable file — and each test skips on
the capabilities it needs, printing what was measured.
`QT_MEDIA_BACKEND` is pinned to whatever `main.cpp` ships per platform,
since `QTEST_MAIN` does not run `main.cpp` and the tests were otherwise
exercising Qt's default backend rather than the one users get.
#### Motivation for adding to Mudlet
Both silent-failure cases are real and user-visible. A game sending a
filename Qt cannot decode, or a file gone from the media cache, would
kill the sound and leak the player's source while reporting nothing; and
`stopMusic()` shortly after `playMusic()` would leak the source every
time on Windows and Linux. A script chaining tracks off
`sysMediaFinished` waited forever in both cases.
They share a root cause with #9566: the deferred release could not tell
what the player was doing when its turn came around. The
`claimSource()`/`continuePlaying()`/`releaseSource()` encapsulation is
the durable part — it turns "remember to bump the counter" from a
convention into something the API does for you.
The test work matters as much as the fixes. The #9566 regression guard
was skipping on every CI job, so it was protecting nothing; both bugs
above were caught only once it actually ran.
#### Other info (issues closed, discussion etc)
Follow-up to #9569 / #9566. No issue number of its own.
Testing notes: the full functional suite passes — 66/66 on Linux, and
the media suite is green on the Ubuntu, Windows and both macOS jobs.
Backends differ in what they can demonstrate, so some media tests skip
by design. Under the environment ctest uses, six of the seven skip on
macOS: the `darwin` backend starts playback synchronously and delivers
`EndOfMedia` before `StoppedState`, so it can stage neither the claim
race nor the #9566 ordering, and it does not decode under
`QT_QPA_PLATFORM=offscreen`. Those paths run on the Ubuntu and Windows
FFmpeg builds, which is where both bugs in this PR were caught. Every
skip prints what the backend could not demonstrate and why, so an inert
guard is visible rather than silent.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-05 00:34:43 -04:00
// stopMusic{} followed by playMusic{} in one script does, must keep the new source. The
// pending cleanup belongs to the track that stopped, and on an asynchronously starting
// backend (see mSynchronousStartReason) the player still reads as stopped while it loads.
Fix looping sounds playing only once (#9569)
#### Brief overview of PR changes/additions
Looping media (`loops=-1`) plays only once on Qt's FFmpeg multimedia
backend.
That backend ends a track by emitting `StoppedState` **first** and
`EndOfMedia` **second**, and it skips the `EndOfMedia` notification when
the playback engine was destroyed in between:
```cpp
if (currentPlaybackEngine)
stateChanged(QMediaPlayer::StoppedState);
if (currentPlaybackEngine)
mediaStatusChanged(QMediaPlayer::EndOfMedia);
```
Mudlet restarts a loop from its `EndOfMedia` handler, but
`TMedia::handlePlayerPlaybackStateChanged()` called `setSource(QUrl())`
immediately on `StoppedState`. That destroyed the engine, so
`EndOfMedia` never arrived and the loop died after one pass.
This defers the cleanup by one event-loop turn and re-checks the
playback state, so a loop restart can claim the player first while a
genuinely stopped player is still torn down.
#### Motivation for adding to Mudlet
Fixes #9566. Ambient/background music tracks stop after a single pass,
which affects any game using `Client.Media.Play` with `loops: -1`, MSP,
or `playMusicFile()`.
The immediate source clearing was introduced by #9237 as a
resource-cleanup measure. That cleanup is preserved here — it just runs
one event-loop turn later, and only if the player is still stopped.
#### Other info (issues closed, discussion etc)
Fixes #9566. Regression introduced by #9237 (merged 2026-04-29), so 4.21
and 4.22 are affected; 4.20.1 and earlier are not.
**Verified on Windows against the FFmpeg backend.** The bug reproduces
on unfixed code there, and this change resolves it without regressing
the cleanup that #9237 added.
It is worth being explicit that this could not be confirmed on macOS:
that platform ships `libdarwinmediaplugin`, which emits `EndOfMedia`
*before* `StoppedState` — the reverse ordering — so the loop restarts
before any cleanup runs and the bug cannot occur there at all. Reverting
the fix on macOS produced identical behaviour. The fix is therefore
specific to backends with the FFmpeg ordering, which is where the issue
was reported.
A functional test is included
(`test/functional_tests/TMediaLoopTest.cpp`) covering both halves of the
contract: a looping track must survive the stop/restart cycle, and a
one-shot track must still be cleaned up. It probes the backend before
asserting and **skips** unless that backend can both finish a clip and
emit `StoppedState` before `EndOfMedia`. Without that guard the looping
assertion passes on broken code, which is exactly what happened on
macOS. Expected results:
- FFmpeg backend → runs and asserts
- macOS darwin backend → skips (cannot exhibit the bug)
- Anywhere media stalls under `QT_QPA_PLATFORM=offscreen`, which the
functional suite forces → skips
So it may well skip in CI; that is intentional and preferable to a test
that always passes.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-02 13:11:37 -04:00
void test_reusedPlayerKeepsTheTrackThatClaimedIt ( )
{
Fix: Sounds going silent when a file fails to load (#9612)
#### Brief overview of PR changes/additions
Follow-up hardening on top of #9569. Two ways a media player could end
up silent while still holding its source, plus the crash and the test
gaps found chasing them.
**A track that fails to load.** Nothing in `TMedia` listened for
`QMediaPlayer::errorOccurred`. A player that was already stopped when
its source failed reports no playback state change — and that signal is
what ends a playback, releases the source and raises `sysMediaFinished`.
The track fell silent holding a file nothing would ever release. The
error is now acted on.
**A track stopped while it is still loading.** Qt already considers an
unstarted player stopped, so `stop()` draws no state change out of one
mid-load, with the same result. This is not a narrow race: on an
asynchronous backend the Linux and Windows runners hit it every time.
`stopMedia()` now ends such a playback itself instead of waiting for a
report that is never coming.
Around those:
- The deferred source release is a single function shared by the stop,
error and teardown paths. Whether the player's own state gets a say
differs between them, so callers pass a `PlaybackEnd`: a stop needs it,
because a restart may be in flight and a player loading its next source
looks identical to a stopped one; a failure must *not* have it, because
a backend can report `PlayingState` for media it has just failed to
load.
- Re-sourcing a player goes through `claimSource()`, `continuePlaying()`
and `releaseSource()`, so the generation bump that tells a pending
release the track has moved on cannot be forgotten at a call site. A
missed bump is what let an earlier revision of #9569 clear the source of
a track that had just been restarted.
- `stopMedia()` empties the playlist, so an explicit stop cannot leave a
loop armed to restart itself from the `EndOfMedia` handler.
- `setupVideo()` failing now releases the source it claimed, and hides
the video widget only under the same `mediaWidget`/`mediaClose` guards
the deferred release uses — previously it could hide a label belonging
to an earlier clip.
- `src/dlgTriggerEditor.cpp` is here for one guard:
`runScheduledCleanReset()` repopulates itself from a `Host` that a
profile teardown has already destroyed. Unrelated to media, but it
crashed the media tests once they started running.
Behaviour worth knowing about when reviewing:
- `sysMediaFinished` now fires for a failed load and for a stop issued
mid-load, where nothing fired before. It is suppressed when the source
has already been released, where it would only have carried an empty
file name and path.
- `purgeMediaCache()` returns `false` when the directory could not be
fully removed, instead of always returning `true`.
- The closing closed caption is suppressed between the passes of a
looping track, and printed by `stopAllMediaPlayers()`, which releases
synchronously.
- `TMedia` gains three read-only diagnostics used by the tests —
`playersHoldingSource()`, `mediaPlayerCount()` and
`playersInPlayingState()`. A deferred release is otherwise unobservable:
`playingMedia()` has already dropped the player, the caption needs
captions enabled, and the video signal needs a widget.
Tests: new slots for a finite `loops=N` track, an explicit stop, an
unplayable source and a reused player. `probeBackend()` measures what
the backend can demonstrate — whether it starts playback at all, decodes
to `EndOfMedia`, orders `EndOfMedia` against `StoppedState`, starts
synchronously, and reports an undecodable file — and each test skips on
the capabilities it needs, printing what was measured.
`QT_MEDIA_BACKEND` is pinned to whatever `main.cpp` ships per platform,
since `QTEST_MAIN` does not run `main.cpp` and the tests were otherwise
exercising Qt's default backend rather than the one users get.
#### Motivation for adding to Mudlet
Both silent-failure cases are real and user-visible. A game sending a
filename Qt cannot decode, or a file gone from the media cache, would
kill the sound and leak the player's source while reporting nothing; and
`stopMusic()` shortly after `playMusic()` would leak the source every
time on Windows and Linux. A script chaining tracks off
`sysMediaFinished` waited forever in both cases.
They share a root cause with #9566: the deferred release could not tell
what the player was doing when its turn came around. The
`claimSource()`/`continuePlaying()`/`releaseSource()` encapsulation is
the durable part — it turns "remember to bump the counter" from a
convention into something the API does for you.
The test work matters as much as the fixes. The #9566 regression guard
was skipping on every CI job, so it was protecting nothing; both bugs
above were caught only once it actually ran.
#### Other info (issues closed, discussion etc)
Follow-up to #9569 / #9566. No issue number of its own.
Testing notes: the full functional suite passes — 66/66 on Linux, and
the media suite is green on the Ubuntu, Windows and both macOS jobs.
Backends differ in what they can demonstrate, so some media tests skip
by design. Under the environment ctest uses, six of the seven skip on
macOS: the `darwin` backend starts playback synchronously and delivers
`EndOfMedia` before `StoppedState`, so it can stage neither the claim
race nor the #9566 ordering, and it does not decode under
`QT_QPA_PLATFORM=offscreen`. Those paths run on the Ubuntu and Windows
FFmpeg builds, which is where both bugs in this PR were caught. Every
skip prints what the backend could not demonstrate and why, so an inert
guard is visible rather than silent.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-05 00:34:43 -04:00
SKIP_OR_FAIL_WITHOUT ( mCannotStartReason ) ;
SKIP_OR_FAIL_WITHOUT ( mSynchronousStartReason ) ;
Fix looping sounds playing only once (#9569)
#### Brief overview of PR changes/additions
Looping media (`loops=-1`) plays only once on Qt's FFmpeg multimedia
backend.
That backend ends a track by emitting `StoppedState` **first** and
`EndOfMedia` **second**, and it skips the `EndOfMedia` notification when
the playback engine was destroyed in between:
```cpp
if (currentPlaybackEngine)
stateChanged(QMediaPlayer::StoppedState);
if (currentPlaybackEngine)
mediaStatusChanged(QMediaPlayer::EndOfMedia);
```
Mudlet restarts a loop from its `EndOfMedia` handler, but
`TMedia::handlePlayerPlaybackStateChanged()` called `setSource(QUrl())`
immediately on `StoppedState`. That destroyed the engine, so
`EndOfMedia` never arrived and the loop died after one pass.
This defers the cleanup by one event-loop turn and re-checks the
playback state, so a loop restart can claim the player first while a
genuinely stopped player is still torn down.
#### Motivation for adding to Mudlet
Fixes #9566. Ambient/background music tracks stop after a single pass,
which affects any game using `Client.Media.Play` with `loops: -1`, MSP,
or `playMusicFile()`.
The immediate source clearing was introduced by #9237 as a
resource-cleanup measure. That cleanup is preserved here — it just runs
one event-loop turn later, and only if the player is still stopped.
#### Other info (issues closed, discussion etc)
Fixes #9566. Regression introduced by #9237 (merged 2026-04-29), so 4.21
and 4.22 are affected; 4.20.1 and earlier are not.
**Verified on Windows against the FFmpeg backend.** The bug reproduces
on unfixed code there, and this change resolves it without regressing
the cleanup that #9237 added.
It is worth being explicit that this could not be confirmed on macOS:
that platform ships `libdarwinmediaplugin`, which emits `EndOfMedia`
*before* `StoppedState` — the reverse ordering — so the loop restarts
before any cleanup runs and the bug cannot occur there at all. Reverting
the fix on macOS produced identical behaviour. The fix is therefore
specific to backends with the FFmpeg ordering, which is where the issue
was reported.
A functional test is included
(`test/functional_tests/TMediaLoopTest.cpp`) covering both halves of the
contract: a looping track must survive the stop/restart cycle, and a
one-shot track must still be cleaned up. It probes the backend before
asserting and **skips** unless that backend can both finish a clip and
emit `StoppedState` before `EndOfMedia`. Without that guard the looping
assertion passes on broken code, which is exactly what happened on
macOS. Expected results:
- FFmpeg backend → runs and asserts
- macOS darwin backend → skips (cannot exhibit the bug)
- Anywhere media stalls under `QT_QPA_PLATFORM=offscreen`, which the
functional suite forces → skips
So it may well skip in CI; that is intentional and preferable to a test
that always passes.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-02 13:11:37 -04:00
auto * media = startProfileAndGetMedia ( ) ;
QVERIFY ( media ) ;
const QString firstFile = writeClip ( qsl ( " first.wav " ) ) ;
const QString secondFile = writeClip ( qsl ( " second.wav " ) ) ;
Fix: Sounds going silent when a file fails to load (#9612)
#### Brief overview of PR changes/additions
Follow-up hardening on top of #9569. Two ways a media player could end
up silent while still holding its source, plus the crash and the test
gaps found chasing them.
**A track that fails to load.** Nothing in `TMedia` listened for
`QMediaPlayer::errorOccurred`. A player that was already stopped when
its source failed reports no playback state change — and that signal is
what ends a playback, releases the source and raises `sysMediaFinished`.
The track fell silent holding a file nothing would ever release. The
error is now acted on.
**A track stopped while it is still loading.** Qt already considers an
unstarted player stopped, so `stop()` draws no state change out of one
mid-load, with the same result. This is not a narrow race: on an
asynchronous backend the Linux and Windows runners hit it every time.
`stopMedia()` now ends such a playback itself instead of waiting for a
report that is never coming.
Around those:
- The deferred source release is a single function shared by the stop,
error and teardown paths. Whether the player's own state gets a say
differs between them, so callers pass a `PlaybackEnd`: a stop needs it,
because a restart may be in flight and a player loading its next source
looks identical to a stopped one; a failure must *not* have it, because
a backend can report `PlayingState` for media it has just failed to
load.
- Re-sourcing a player goes through `claimSource()`, `continuePlaying()`
and `releaseSource()`, so the generation bump that tells a pending
release the track has moved on cannot be forgotten at a call site. A
missed bump is what let an earlier revision of #9569 clear the source of
a track that had just been restarted.
- `stopMedia()` empties the playlist, so an explicit stop cannot leave a
loop armed to restart itself from the `EndOfMedia` handler.
- `setupVideo()` failing now releases the source it claimed, and hides
the video widget only under the same `mediaWidget`/`mediaClose` guards
the deferred release uses — previously it could hide a label belonging
to an earlier clip.
- `src/dlgTriggerEditor.cpp` is here for one guard:
`runScheduledCleanReset()` repopulates itself from a `Host` that a
profile teardown has already destroyed. Unrelated to media, but it
crashed the media tests once they started running.
Behaviour worth knowing about when reviewing:
- `sysMediaFinished` now fires for a failed load and for a stop issued
mid-load, where nothing fired before. It is suppressed when the source
has already been released, where it would only have carried an empty
file name and path.
- `purgeMediaCache()` returns `false` when the directory could not be
fully removed, instead of always returning `true`.
- The closing closed caption is suppressed between the passes of a
looping track, and printed by `stopAllMediaPlayers()`, which releases
synchronously.
- `TMedia` gains three read-only diagnostics used by the tests —
`playersHoldingSource()`, `mediaPlayerCount()` and
`playersInPlayingState()`. A deferred release is otherwise unobservable:
`playingMedia()` has already dropped the player, the caption needs
captions enabled, and the video signal needs a widget.
Tests: new slots for a finite `loops=N` track, an explicit stop, an
unplayable source and a reused player. `probeBackend()` measures what
the backend can demonstrate — whether it starts playback at all, decodes
to `EndOfMedia`, orders `EndOfMedia` against `StoppedState`, starts
synchronously, and reports an undecodable file — and each test skips on
the capabilities it needs, printing what was measured.
`QT_MEDIA_BACKEND` is pinned to whatever `main.cpp` ships per platform,
since `QTEST_MAIN` does not run `main.cpp` and the tests were otherwise
exercising Qt's default backend rather than the one users get.
#### Motivation for adding to Mudlet
Both silent-failure cases are real and user-visible. A game sending a
filename Qt cannot decode, or a file gone from the media cache, would
kill the sound and leak the player's source while reporting nothing; and
`stopMusic()` shortly after `playMusic()` would leak the source every
time on Windows and Linux. A script chaining tracks off
`sysMediaFinished` waited forever in both cases.
They share a root cause with #9566: the deferred release could not tell
what the player was doing when its turn came around. The
`claimSource()`/`continuePlaying()`/`releaseSource()` encapsulation is
the durable part — it turns "remember to bump the counter" from a
convention into something the API does for you.
The test work matters as much as the fixes. The #9566 regression guard
was skipping on every CI job, so it was protecting nothing; both bugs
above were caught only once it actually ran.
#### Other info (issues closed, discussion etc)
Follow-up to #9569 / #9566. No issue number of its own.
Testing notes: the full functional suite passes — 66/66 on Linux, and
the media suite is green on the Ubuntu, Windows and both macOS jobs.
Backends differ in what they can demonstrate, so some media tests skip
by design. Under the environment ctest uses, six of the seven skip on
macOS: the `darwin` backend starts playback synchronously and delivers
`EndOfMedia` before `StoppedState`, so it can stage neither the claim
race nor the #9566 ordering, and it does not decode under
`QT_QPA_PLATFORM=offscreen`. Those paths run on the Ubuntu and Windows
FFmpeg builds, which is where both bugs in this PR were caught. Every
skip prints what the backend could not demonstrate and why, so an inert
guard is visible rather than silent.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-05 00:34:43 -04:00
QVERIFY ( ! firstFile . isEmpty ( ) & & ! secondFile . isEmpty ( ) ) ;
Fix looping sounds playing only once (#9569)
#### Brief overview of PR changes/additions
Looping media (`loops=-1`) plays only once on Qt's FFmpeg multimedia
backend.
That backend ends a track by emitting `StoppedState` **first** and
`EndOfMedia` **second**, and it skips the `EndOfMedia` notification when
the playback engine was destroyed in between:
```cpp
if (currentPlaybackEngine)
stateChanged(QMediaPlayer::StoppedState);
if (currentPlaybackEngine)
mediaStatusChanged(QMediaPlayer::EndOfMedia);
```
Mudlet restarts a loop from its `EndOfMedia` handler, but
`TMedia::handlePlayerPlaybackStateChanged()` called `setSource(QUrl())`
immediately on `StoppedState`. That destroyed the engine, so
`EndOfMedia` never arrived and the loop died after one pass.
This defers the cleanup by one event-loop turn and re-checks the
playback state, so a loop restart can claim the player first while a
genuinely stopped player is still torn down.
#### Motivation for adding to Mudlet
Fixes #9566. Ambient/background music tracks stop after a single pass,
which affects any game using `Client.Media.Play` with `loops: -1`, MSP,
or `playMusicFile()`.
The immediate source clearing was introduced by #9237 as a
resource-cleanup measure. That cleanup is preserved here — it just runs
one event-loop turn later, and only if the player is still stopped.
#### Other info (issues closed, discussion etc)
Fixes #9566. Regression introduced by #9237 (merged 2026-04-29), so 4.21
and 4.22 are affected; 4.20.1 and earlier are not.
**Verified on Windows against the FFmpeg backend.** The bug reproduces
on unfixed code there, and this change resolves it without regressing
the cleanup that #9237 added.
It is worth being explicit that this could not be confirmed on macOS:
that platform ships `libdarwinmediaplugin`, which emits `EndOfMedia`
*before* `StoppedState` — the reverse ordering — so the loop restarts
before any cleanup runs and the bug cannot occur there at all. Reverting
the fix on macOS produced identical behaviour. The fix is therefore
specific to backends with the FFmpeg ordering, which is where the issue
was reported.
A functional test is included
(`test/functional_tests/TMediaLoopTest.cpp`) covering both halves of the
contract: a looping track must survive the stop/restart cycle, and a
one-shot track must still be cleaned up. It probes the backend before
asserting and **skips** unless that backend can both finish a clip and
emit `StoppedState` before `EndOfMedia`. Without that guard the looping
assertion passes on broken code, which is exactly what happened on
macOS. Expected results:
- FFmpeg backend → runs and asserts
- macOS darwin backend → skips (cannot exhibit the bug)
- Anywhere media stalls under `QT_QPA_PLATFORM=offscreen`, which the
functional suite forces → skips
So it may well skip in CI; that is intentional and preferable to a test
that always passes.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-02 13:11:37 -04:00
TMediaData first = clipData ( firstFile ) ;
first . setMediaLoops ( TMediaData : : MediaLoopsRepeat ) ;
media - > playMedia ( first ) ;
Fix: Sounds going silent when a file fails to load (#9612)
#### Brief overview of PR changes/additions
Follow-up hardening on top of #9569. Two ways a media player could end
up silent while still holding its source, plus the crash and the test
gaps found chasing them.
**A track that fails to load.** Nothing in `TMedia` listened for
`QMediaPlayer::errorOccurred`. A player that was already stopped when
its source failed reports no playback state change — and that signal is
what ends a playback, releases the source and raises `sysMediaFinished`.
The track fell silent holding a file nothing would ever release. The
error is now acted on.
**A track stopped while it is still loading.** Qt already considers an
unstarted player stopped, so `stop()` draws no state change out of one
mid-load, with the same result. This is not a narrow race: on an
asynchronous backend the Linux and Windows runners hit it every time.
`stopMedia()` now ends such a playback itself instead of waiting for a
report that is never coming.
Around those:
- The deferred source release is a single function shared by the stop,
error and teardown paths. Whether the player's own state gets a say
differs between them, so callers pass a `PlaybackEnd`: a stop needs it,
because a restart may be in flight and a player loading its next source
looks identical to a stopped one; a failure must *not* have it, because
a backend can report `PlayingState` for media it has just failed to
load.
- Re-sourcing a player goes through `claimSource()`, `continuePlaying()`
and `releaseSource()`, so the generation bump that tells a pending
release the track has moved on cannot be forgotten at a call site. A
missed bump is what let an earlier revision of #9569 clear the source of
a track that had just been restarted.
- `stopMedia()` empties the playlist, so an explicit stop cannot leave a
loop armed to restart itself from the `EndOfMedia` handler.
- `setupVideo()` failing now releases the source it claimed, and hides
the video widget only under the same `mediaWidget`/`mediaClose` guards
the deferred release uses — previously it could hide a label belonging
to an earlier clip.
- `src/dlgTriggerEditor.cpp` is here for one guard:
`runScheduledCleanReset()` repopulates itself from a `Host` that a
profile teardown has already destroyed. Unrelated to media, but it
crashed the media tests once they started running.
Behaviour worth knowing about when reviewing:
- `sysMediaFinished` now fires for a failed load and for a stop issued
mid-load, where nothing fired before. It is suppressed when the source
has already been released, where it would only have carried an empty
file name and path.
- `purgeMediaCache()` returns `false` when the directory could not be
fully removed, instead of always returning `true`.
- The closing closed caption is suppressed between the passes of a
looping track, and printed by `stopAllMediaPlayers()`, which releases
synchronously.
- `TMedia` gains three read-only diagnostics used by the tests —
`playersHoldingSource()`, `mediaPlayerCount()` and
`playersInPlayingState()`. A deferred release is otherwise unobservable:
`playingMedia()` has already dropped the player, the caption needs
captions enabled, and the video signal needs a widget.
Tests: new slots for a finite `loops=N` track, an explicit stop, an
unplayable source and a reused player. `probeBackend()` measures what
the backend can demonstrate — whether it starts playback at all, decodes
to `EndOfMedia`, orders `EndOfMedia` against `StoppedState`, starts
synchronously, and reports an undecodable file — and each test skips on
the capabilities it needs, printing what was measured.
`QT_MEDIA_BACKEND` is pinned to whatever `main.cpp` ships per platform,
since `QTEST_MAIN` does not run `main.cpp` and the tests were otherwise
exercising Qt's default backend rather than the one users get.
#### Motivation for adding to Mudlet
Both silent-failure cases are real and user-visible. A game sending a
filename Qt cannot decode, or a file gone from the media cache, would
kill the sound and leak the player's source while reporting nothing; and
`stopMusic()` shortly after `playMusic()` would leak the source every
time on Windows and Linux. A script chaining tracks off
`sysMediaFinished` waited forever in both cases.
They share a root cause with #9566: the deferred release could not tell
what the player was doing when its turn came around. The
`claimSource()`/`continuePlaying()`/`releaseSource()` encapsulation is
the durable part — it turns "remember to bump the counter" from a
convention into something the API does for you.
The test work matters as much as the fixes. The #9566 regression guard
was skipping on every CI job, so it was protecting nothing; both bugs
above were caught only once it actually ran.
#### Other info (issues closed, discussion etc)
Follow-up to #9569 / #9566. No issue number of its own.
Testing notes: the full functional suite passes — 66/66 on Linux, and
the media suite is green on the Ubuntu, Windows and both macOS jobs.
Backends differ in what they can demonstrate, so some media tests skip
by design. Under the environment ctest uses, six of the seven skip on
macOS: the `darwin` backend starts playback synchronously and delivers
`EndOfMedia` before `StoppedState`, so it can stage neither the claim
race nor the #9566 ordering, and it does not decode under
`QT_QPA_PLATFORM=offscreen`. Those paths run on the Ubuntu and Windows
FFmpeg builds, which is where both bugs in this PR were caught. Every
skip prints what the backend could not demonstrate and why, so an inert
guard is visible rather than silent.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-05 00:34:43 -04:00
QVERIFY2 ( waitForPlaybackStarted ( media , firstFile ) , " The first track never started playing. " ) ;
const int playerCount = media - > mediaPlayerCount ( ) ;
Fix looping sounds playing only once (#9569)
#### Brief overview of PR changes/additions
Looping media (`loops=-1`) plays only once on Qt's FFmpeg multimedia
backend.
That backend ends a track by emitting `StoppedState` **first** and
`EndOfMedia` **second**, and it skips the `EndOfMedia` notification when
the playback engine was destroyed in between:
```cpp
if (currentPlaybackEngine)
stateChanged(QMediaPlayer::StoppedState);
if (currentPlaybackEngine)
mediaStatusChanged(QMediaPlayer::EndOfMedia);
```
Mudlet restarts a loop from its `EndOfMedia` handler, but
`TMedia::handlePlayerPlaybackStateChanged()` called `setSource(QUrl())`
immediately on `StoppedState`. That destroyed the engine, so
`EndOfMedia` never arrived and the loop died after one pass.
This defers the cleanup by one event-loop turn and re-checks the
playback state, so a loop restart can claim the player first while a
genuinely stopped player is still torn down.
#### Motivation for adding to Mudlet
Fixes #9566. Ambient/background music tracks stop after a single pass,
which affects any game using `Client.Media.Play` with `loops: -1`, MSP,
or `playMusicFile()`.
The immediate source clearing was introduced by #9237 as a
resource-cleanup measure. That cleanup is preserved here — it just runs
one event-loop turn later, and only if the player is still stopped.
#### Other info (issues closed, discussion etc)
Fixes #9566. Regression introduced by #9237 (merged 2026-04-29), so 4.21
and 4.22 are affected; 4.20.1 and earlier are not.
**Verified on Windows against the FFmpeg backend.** The bug reproduces
on unfixed code there, and this change resolves it without regressing
the cleanup that #9237 added.
It is worth being explicit that this could not be confirmed on macOS:
that platform ships `libdarwinmediaplugin`, which emits `EndOfMedia`
*before* `StoppedState` — the reverse ordering — so the loop restarts
before any cleanup runs and the bug cannot occur there at all. Reverting
the fix on macOS produced identical behaviour. The fix is therefore
specific to backends with the FFmpeg ordering, which is where the issue
was reported.
A functional test is included
(`test/functional_tests/TMediaLoopTest.cpp`) covering both halves of the
contract: a looping track must survive the stop/restart cycle, and a
one-shot track must still be cleaned up. It probes the backend before
asserting and **skips** unless that backend can both finish a clip and
emit `StoppedState` before `EndOfMedia`. Without that guard the looping
assertion passes on broken code, which is exactly what happened on
macOS. Expected results:
- FFmpeg backend → runs and asserts
- macOS darwin backend → skips (cannot exhibit the bug)
- Anywhere media stalls under `QT_QPA_PLATFORM=offscreen`, which the
functional suite forces → skips
So it may well skip in CI; that is intentional and preferable to a test
that always passes.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-02 13:11:37 -04:00
TMediaData stopFirst = clipData ( firstFile ) ;
media - > stopMedia ( stopFirst ) ;
TMediaData second = clipData ( secondFile ) ;
second . setMediaLoops ( TMediaData : : MediaLoopsRepeat ) ;
media - > playMedia ( second ) ;
QVERIFY2 ( waitForPlaying ( media , secondFile ) , " The replacement track never started playing. " ) ;
Fix: Sounds going silent when a file fails to load (#9612)
#### Brief overview of PR changes/additions
Follow-up hardening on top of #9569. Two ways a media player could end
up silent while still holding its source, plus the crash and the test
gaps found chasing them.
**A track that fails to load.** Nothing in `TMedia` listened for
`QMediaPlayer::errorOccurred`. A player that was already stopped when
its source failed reports no playback state change — and that signal is
what ends a playback, releases the source and raises `sysMediaFinished`.
The track fell silent holding a file nothing would ever release. The
error is now acted on.
**A track stopped while it is still loading.** Qt already considers an
unstarted player stopped, so `stop()` draws no state change out of one
mid-load, with the same result. This is not a narrow race: on an
asynchronous backend the Linux and Windows runners hit it every time.
`stopMedia()` now ends such a playback itself instead of waiting for a
report that is never coming.
Around those:
- The deferred source release is a single function shared by the stop,
error and teardown paths. Whether the player's own state gets a say
differs between them, so callers pass a `PlaybackEnd`: a stop needs it,
because a restart may be in flight and a player loading its next source
looks identical to a stopped one; a failure must *not* have it, because
a backend can report `PlayingState` for media it has just failed to
load.
- Re-sourcing a player goes through `claimSource()`, `continuePlaying()`
and `releaseSource()`, so the generation bump that tells a pending
release the track has moved on cannot be forgotten at a call site. A
missed bump is what let an earlier revision of #9569 clear the source of
a track that had just been restarted.
- `stopMedia()` empties the playlist, so an explicit stop cannot leave a
loop armed to restart itself from the `EndOfMedia` handler.
- `setupVideo()` failing now releases the source it claimed, and hides
the video widget only under the same `mediaWidget`/`mediaClose` guards
the deferred release uses — previously it could hide a label belonging
to an earlier clip.
- `src/dlgTriggerEditor.cpp` is here for one guard:
`runScheduledCleanReset()` repopulates itself from a `Host` that a
profile teardown has already destroyed. Unrelated to media, but it
crashed the media tests once they started running.
Behaviour worth knowing about when reviewing:
- `sysMediaFinished` now fires for a failed load and for a stop issued
mid-load, where nothing fired before. It is suppressed when the source
has already been released, where it would only have carried an empty
file name and path.
- `purgeMediaCache()` returns `false` when the directory could not be
fully removed, instead of always returning `true`.
- The closing closed caption is suppressed between the passes of a
looping track, and printed by `stopAllMediaPlayers()`, which releases
synchronously.
- `TMedia` gains three read-only diagnostics used by the tests —
`playersHoldingSource()`, `mediaPlayerCount()` and
`playersInPlayingState()`. A deferred release is otherwise unobservable:
`playingMedia()` has already dropped the player, the caption needs
captions enabled, and the video signal needs a widget.
Tests: new slots for a finite `loops=N` track, an explicit stop, an
unplayable source and a reused player. `probeBackend()` measures what
the backend can demonstrate — whether it starts playback at all, decodes
to `EndOfMedia`, orders `EndOfMedia` against `StoppedState`, starts
synchronously, and reports an undecodable file — and each test skips on
the capabilities it needs, printing what was measured.
`QT_MEDIA_BACKEND` is pinned to whatever `main.cpp` ships per platform,
since `QTEST_MAIN` does not run `main.cpp` and the tests were otherwise
exercising Qt's default backend rather than the one users get.
#### Motivation for adding to Mudlet
Both silent-failure cases are real and user-visible. A game sending a
filename Qt cannot decode, or a file gone from the media cache, would
kill the sound and leak the player's source while reporting nothing; and
`stopMusic()` shortly after `playMusic()` would leak the source every
time on Windows and Linux. A script chaining tracks off
`sysMediaFinished` waited forever in both cases.
They share a root cause with #9566: the deferred release could not tell
what the player was doing when its turn came around. The
`claimSource()`/`continuePlaying()`/`releaseSource()` encapsulation is
the durable part — it turns "remember to bump the counter" from a
convention into something the API does for you.
The test work matters as much as the fixes. The #9566 regression guard
was skipping on every CI job, so it was protecting nothing; both bugs
above were caught only once it actually ran.
#### Other info (issues closed, discussion etc)
Follow-up to #9569 / #9566. No issue number of its own.
Testing notes: the full functional suite passes — 66/66 on Linux, and
the media suite is green on the Ubuntu, Windows and both macOS jobs.
Backends differ in what they can demonstrate, so some media tests skip
by design. Under the environment ctest uses, six of the seven skip on
macOS: the `darwin` backend starts playback synchronously and delivers
`EndOfMedia` before `StoppedState`, so it can stage neither the claim
race nor the #9566 ordering, and it does not decode under
`QT_QPA_PLATFORM=offscreen`. Those paths run on the Ubuntu and Windows
FFmpeg builds, which is where both bugs in this PR were caught. Every
skip prints what the backend could not demonstrate and why, so an inert
guard is visible rather than silent.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-05 00:34:43 -04:00
// Without this the test passes vacuously on a second player, having never exercised
// the claim the deferred cleanup has to notice.
QCOMPARE ( media - > mediaPlayerCount ( ) , playerCount ) ;
Fix looping sounds playing only once (#9569)
#### Brief overview of PR changes/additions
Looping media (`loops=-1`) plays only once on Qt's FFmpeg multimedia
backend.
That backend ends a track by emitting `StoppedState` **first** and
`EndOfMedia` **second**, and it skips the `EndOfMedia` notification when
the playback engine was destroyed in between:
```cpp
if (currentPlaybackEngine)
stateChanged(QMediaPlayer::StoppedState);
if (currentPlaybackEngine)
mediaStatusChanged(QMediaPlayer::EndOfMedia);
```
Mudlet restarts a loop from its `EndOfMedia` handler, but
`TMedia::handlePlayerPlaybackStateChanged()` called `setSource(QUrl())`
immediately on `StoppedState`. That destroyed the engine, so
`EndOfMedia` never arrived and the loop died after one pass.
This defers the cleanup by one event-loop turn and re-checks the
playback state, so a loop restart can claim the player first while a
genuinely stopped player is still torn down.
#### Motivation for adding to Mudlet
Fixes #9566. Ambient/background music tracks stop after a single pass,
which affects any game using `Client.Media.Play` with `loops: -1`, MSP,
or `playMusicFile()`.
The immediate source clearing was introduced by #9237 as a
resource-cleanup measure. That cleanup is preserved here — it just runs
one event-loop turn later, and only if the player is still stopped.
#### Other info (issues closed, discussion etc)
Fixes #9566. Regression introduced by #9237 (merged 2026-04-29), so 4.21
and 4.22 are affected; 4.20.1 and earlier are not.
**Verified on Windows against the FFmpeg backend.** The bug reproduces
on unfixed code there, and this change resolves it without regressing
the cleanup that #9237 added.
It is worth being explicit that this could not be confirmed on macOS:
that platform ships `libdarwinmediaplugin`, which emits `EndOfMedia`
*before* `StoppedState` — the reverse ordering — so the loop restarts
before any cleanup runs and the bug cannot occur there at all. Reverting
the fix on macOS produced identical behaviour. The fix is therefore
specific to backends with the FFmpeg ordering, which is where the issue
was reported.
A functional test is included
(`test/functional_tests/TMediaLoopTest.cpp`) covering both halves of the
contract: a looping track must survive the stop/restart cycle, and a
one-shot track must still be cleaned up. It probes the backend before
asserting and **skips** unless that backend can both finish a clip and
emit `StoppedState` before `EndOfMedia`. Without that guard the looping
assertion passes on broken code, which is exactly what happened on
macOS. Expected results:
- FFmpeg backend → runs and asserts
- macOS darwin backend → skips (cannot exhibit the bug)
- Anywhere media stalls under `QT_QPA_PLATFORM=offscreen`, which the
functional suite forces → skips
So it may well skip in CI; that is intentional and preferable to a test
that always passes.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-02 13:11:37 -04:00
// Past the turn the stopped track's cleanup was scheduled for.
QTest : : qWait ( clipMs ) ;
Fix: Sounds going silent when a file fails to load (#9612)
#### Brief overview of PR changes/additions
Follow-up hardening on top of #9569. Two ways a media player could end
up silent while still holding its source, plus the crash and the test
gaps found chasing them.
**A track that fails to load.** Nothing in `TMedia` listened for
`QMediaPlayer::errorOccurred`. A player that was already stopped when
its source failed reports no playback state change — and that signal is
what ends a playback, releases the source and raises `sysMediaFinished`.
The track fell silent holding a file nothing would ever release. The
error is now acted on.
**A track stopped while it is still loading.** Qt already considers an
unstarted player stopped, so `stop()` draws no state change out of one
mid-load, with the same result. This is not a narrow race: on an
asynchronous backend the Linux and Windows runners hit it every time.
`stopMedia()` now ends such a playback itself instead of waiting for a
report that is never coming.
Around those:
- The deferred source release is a single function shared by the stop,
error and teardown paths. Whether the player's own state gets a say
differs between them, so callers pass a `PlaybackEnd`: a stop needs it,
because a restart may be in flight and a player loading its next source
looks identical to a stopped one; a failure must *not* have it, because
a backend can report `PlayingState` for media it has just failed to
load.
- Re-sourcing a player goes through `claimSource()`, `continuePlaying()`
and `releaseSource()`, so the generation bump that tells a pending
release the track has moved on cannot be forgotten at a call site. A
missed bump is what let an earlier revision of #9569 clear the source of
a track that had just been restarted.
- `stopMedia()` empties the playlist, so an explicit stop cannot leave a
loop armed to restart itself from the `EndOfMedia` handler.
- `setupVideo()` failing now releases the source it claimed, and hides
the video widget only under the same `mediaWidget`/`mediaClose` guards
the deferred release uses — previously it could hide a label belonging
to an earlier clip.
- `src/dlgTriggerEditor.cpp` is here for one guard:
`runScheduledCleanReset()` repopulates itself from a `Host` that a
profile teardown has already destroyed. Unrelated to media, but it
crashed the media tests once they started running.
Behaviour worth knowing about when reviewing:
- `sysMediaFinished` now fires for a failed load and for a stop issued
mid-load, where nothing fired before. It is suppressed when the source
has already been released, where it would only have carried an empty
file name and path.
- `purgeMediaCache()` returns `false` when the directory could not be
fully removed, instead of always returning `true`.
- The closing closed caption is suppressed between the passes of a
looping track, and printed by `stopAllMediaPlayers()`, which releases
synchronously.
- `TMedia` gains three read-only diagnostics used by the tests —
`playersHoldingSource()`, `mediaPlayerCount()` and
`playersInPlayingState()`. A deferred release is otherwise unobservable:
`playingMedia()` has already dropped the player, the caption needs
captions enabled, and the video signal needs a widget.
Tests: new slots for a finite `loops=N` track, an explicit stop, an
unplayable source and a reused player. `probeBackend()` measures what
the backend can demonstrate — whether it starts playback at all, decodes
to `EndOfMedia`, orders `EndOfMedia` against `StoppedState`, starts
synchronously, and reports an undecodable file — and each test skips on
the capabilities it needs, printing what was measured.
`QT_MEDIA_BACKEND` is pinned to whatever `main.cpp` ships per platform,
since `QTEST_MAIN` does not run `main.cpp` and the tests were otherwise
exercising Qt's default backend rather than the one users get.
#### Motivation for adding to Mudlet
Both silent-failure cases are real and user-visible. A game sending a
filename Qt cannot decode, or a file gone from the media cache, would
kill the sound and leak the player's source while reporting nothing; and
`stopMusic()` shortly after `playMusic()` would leak the source every
time on Windows and Linux. A script chaining tracks off
`sysMediaFinished` waited forever in both cases.
They share a root cause with #9566: the deferred release could not tell
what the player was doing when its turn came around. The
`claimSource()`/`continuePlaying()`/`releaseSource()` encapsulation is
the durable part — it turns "remember to bump the counter" from a
convention into something the API does for you.
The test work matters as much as the fixes. The #9566 regression guard
was skipping on every CI job, so it was protecting nothing; both bugs
above were caught only once it actually ran.
#### Other info (issues closed, discussion etc)
Follow-up to #9569 / #9566. No issue number of its own.
Testing notes: the full functional suite passes — 66/66 on Linux, and
the media suite is green on the Ubuntu, Windows and both macOS jobs.
Backends differ in what they can demonstrate, so some media tests skip
by design. Under the environment ctest uses, six of the seven skip on
macOS: the `darwin` backend starts playback synchronously and delivers
`EndOfMedia` before `StoppedState`, so it can stage neither the claim
race nor the #9566 ordering, and it does not decode under
`QT_QPA_PLATFORM=offscreen`. Those paths run on the Ubuntu and Windows
FFmpeg builds, which is where both bugs in this PR were caught. Every
skip prints what the backend could not demonstrate and why, so an inert
guard is visible rather than silent.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-05 00:34:43 -04:00
QVERIFY2 ( waitForPlaying ( media , secondFile , 2 s ) , " The replacement track was cut off - the previous track's deferred cleanup cleared the source out from under it. " ) ;
Fix looping sounds playing only once (#9569)
#### Brief overview of PR changes/additions
Looping media (`loops=-1`) plays only once on Qt's FFmpeg multimedia
backend.
That backend ends a track by emitting `StoppedState` **first** and
`EndOfMedia` **second**, and it skips the `EndOfMedia` notification when
the playback engine was destroyed in between:
```cpp
if (currentPlaybackEngine)
stateChanged(QMediaPlayer::StoppedState);
if (currentPlaybackEngine)
mediaStatusChanged(QMediaPlayer::EndOfMedia);
```
Mudlet restarts a loop from its `EndOfMedia` handler, but
`TMedia::handlePlayerPlaybackStateChanged()` called `setSource(QUrl())`
immediately on `StoppedState`. That destroyed the engine, so
`EndOfMedia` never arrived and the loop died after one pass.
This defers the cleanup by one event-loop turn and re-checks the
playback state, so a loop restart can claim the player first while a
genuinely stopped player is still torn down.
#### Motivation for adding to Mudlet
Fixes #9566. Ambient/background music tracks stop after a single pass,
which affects any game using `Client.Media.Play` with `loops: -1`, MSP,
or `playMusicFile()`.
The immediate source clearing was introduced by #9237 as a
resource-cleanup measure. That cleanup is preserved here — it just runs
one event-loop turn later, and only if the player is still stopped.
#### Other info (issues closed, discussion etc)
Fixes #9566. Regression introduced by #9237 (merged 2026-04-29), so 4.21
and 4.22 are affected; 4.20.1 and earlier are not.
**Verified on Windows against the FFmpeg backend.** The bug reproduces
on unfixed code there, and this change resolves it without regressing
the cleanup that #9237 added.
It is worth being explicit that this could not be confirmed on macOS:
that platform ships `libdarwinmediaplugin`, which emits `EndOfMedia`
*before* `StoppedState` — the reverse ordering — so the loop restarts
before any cleanup runs and the bug cannot occur there at all. Reverting
the fix on macOS produced identical behaviour. The fix is therefore
specific to backends with the FFmpeg ordering, which is where the issue
was reported.
A functional test is included
(`test/functional_tests/TMediaLoopTest.cpp`) covering both halves of the
contract: a looping track must survive the stop/restart cycle, and a
one-shot track must still be cleaned up. It probes the backend before
asserting and **skips** unless that backend can both finish a clip and
emit `StoppedState` before `EndOfMedia`. Without that guard the looping
assertion passes on broken code, which is exactly what happened on
macOS. Expected results:
- FFmpeg backend → runs and asserts
- macOS darwin backend → skips (cannot exhibit the bug)
- Anywhere media stalls under `QT_QPA_PLATFORM=offscreen`, which the
functional suite forces → skips
So it may well skip in CI; that is intentional and preferable to a test
that always passes.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-02 13:11:37 -04:00
}
Fix: Sounds going silent when a file fails to load (#9612)
#### Brief overview of PR changes/additions
Follow-up hardening on top of #9569. Two ways a media player could end
up silent while still holding its source, plus the crash and the test
gaps found chasing them.
**A track that fails to load.** Nothing in `TMedia` listened for
`QMediaPlayer::errorOccurred`. A player that was already stopped when
its source failed reports no playback state change — and that signal is
what ends a playback, releases the source and raises `sysMediaFinished`.
The track fell silent holding a file nothing would ever release. The
error is now acted on.
**A track stopped while it is still loading.** Qt already considers an
unstarted player stopped, so `stop()` draws no state change out of one
mid-load, with the same result. This is not a narrow race: on an
asynchronous backend the Linux and Windows runners hit it every time.
`stopMedia()` now ends such a playback itself instead of waiting for a
report that is never coming.
Around those:
- The deferred source release is a single function shared by the stop,
error and teardown paths. Whether the player's own state gets a say
differs between them, so callers pass a `PlaybackEnd`: a stop needs it,
because a restart may be in flight and a player loading its next source
looks identical to a stopped one; a failure must *not* have it, because
a backend can report `PlayingState` for media it has just failed to
load.
- Re-sourcing a player goes through `claimSource()`, `continuePlaying()`
and `releaseSource()`, so the generation bump that tells a pending
release the track has moved on cannot be forgotten at a call site. A
missed bump is what let an earlier revision of #9569 clear the source of
a track that had just been restarted.
- `stopMedia()` empties the playlist, so an explicit stop cannot leave a
loop armed to restart itself from the `EndOfMedia` handler.
- `setupVideo()` failing now releases the source it claimed, and hides
the video widget only under the same `mediaWidget`/`mediaClose` guards
the deferred release uses — previously it could hide a label belonging
to an earlier clip.
- `src/dlgTriggerEditor.cpp` is here for one guard:
`runScheduledCleanReset()` repopulates itself from a `Host` that a
profile teardown has already destroyed. Unrelated to media, but it
crashed the media tests once they started running.
Behaviour worth knowing about when reviewing:
- `sysMediaFinished` now fires for a failed load and for a stop issued
mid-load, where nothing fired before. It is suppressed when the source
has already been released, where it would only have carried an empty
file name and path.
- `purgeMediaCache()` returns `false` when the directory could not be
fully removed, instead of always returning `true`.
- The closing closed caption is suppressed between the passes of a
looping track, and printed by `stopAllMediaPlayers()`, which releases
synchronously.
- `TMedia` gains three read-only diagnostics used by the tests —
`playersHoldingSource()`, `mediaPlayerCount()` and
`playersInPlayingState()`. A deferred release is otherwise unobservable:
`playingMedia()` has already dropped the player, the caption needs
captions enabled, and the video signal needs a widget.
Tests: new slots for a finite `loops=N` track, an explicit stop, an
unplayable source and a reused player. `probeBackend()` measures what
the backend can demonstrate — whether it starts playback at all, decodes
to `EndOfMedia`, orders `EndOfMedia` against `StoppedState`, starts
synchronously, and reports an undecodable file — and each test skips on
the capabilities it needs, printing what was measured.
`QT_MEDIA_BACKEND` is pinned to whatever `main.cpp` ships per platform,
since `QTEST_MAIN` does not run `main.cpp` and the tests were otherwise
exercising Qt's default backend rather than the one users get.
#### Motivation for adding to Mudlet
Both silent-failure cases are real and user-visible. A game sending a
filename Qt cannot decode, or a file gone from the media cache, would
kill the sound and leak the player's source while reporting nothing; and
`stopMusic()` shortly after `playMusic()` would leak the source every
time on Windows and Linux. A script chaining tracks off
`sysMediaFinished` waited forever in both cases.
They share a root cause with #9566: the deferred release could not tell
what the player was doing when its turn came around. The
`claimSource()`/`continuePlaying()`/`releaseSource()` encapsulation is
the durable part — it turns "remember to bump the counter" from a
convention into something the API does for you.
The test work matters as much as the fixes. The #9566 regression guard
was skipping on every CI job, so it was protecting nothing; both bugs
above were caught only once it actually ran.
#### Other info (issues closed, discussion etc)
Follow-up to #9569 / #9566. No issue number of its own.
Testing notes: the full functional suite passes — 66/66 on Linux, and
the media suite is green on the Ubuntu, Windows and both macOS jobs.
Backends differ in what they can demonstrate, so some media tests skip
by design. Under the environment ctest uses, six of the seven skip on
macOS: the `darwin` backend starts playback synchronously and delivers
`EndOfMedia` before `StoppedState`, so it can stage neither the claim
race nor the #9566 ordering, and it does not decode under
`QT_QPA_PLATFORM=offscreen`. Those paths run on the Ubuntu and Windows
FFmpeg builds, which is where both bugs in this PR were caught. Every
skip prints what the backend could not demonstrate and why, so an inert
guard is visible rather than silent.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-05 00:34:43 -04:00
// continue=false restarts a track by stopping it and re-sourcing the same player inside
// one call. That player is matched rather than newly acquired, so the restart has to
// register the claim itself or the stop it just performed clears the source it just set.
Fix looping sounds playing only once (#9569)
#### Brief overview of PR changes/additions
Looping media (`loops=-1`) plays only once on Qt's FFmpeg multimedia
backend.
That backend ends a track by emitting `StoppedState` **first** and
`EndOfMedia` **second**, and it skips the `EndOfMedia` notification when
the playback engine was destroyed in between:
```cpp
if (currentPlaybackEngine)
stateChanged(QMediaPlayer::StoppedState);
if (currentPlaybackEngine)
mediaStatusChanged(QMediaPlayer::EndOfMedia);
```
Mudlet restarts a loop from its `EndOfMedia` handler, but
`TMedia::handlePlayerPlaybackStateChanged()` called `setSource(QUrl())`
immediately on `StoppedState`. That destroyed the engine, so
`EndOfMedia` never arrived and the loop died after one pass.
This defers the cleanup by one event-loop turn and re-checks the
playback state, so a loop restart can claim the player first while a
genuinely stopped player is still torn down.
#### Motivation for adding to Mudlet
Fixes #9566. Ambient/background music tracks stop after a single pass,
which affects any game using `Client.Media.Play` with `loops: -1`, MSP,
or `playMusicFile()`.
The immediate source clearing was introduced by #9237 as a
resource-cleanup measure. That cleanup is preserved here — it just runs
one event-loop turn later, and only if the player is still stopped.
#### Other info (issues closed, discussion etc)
Fixes #9566. Regression introduced by #9237 (merged 2026-04-29), so 4.21
and 4.22 are affected; 4.20.1 and earlier are not.
**Verified on Windows against the FFmpeg backend.** The bug reproduces
on unfixed code there, and this change resolves it without regressing
the cleanup that #9237 added.
It is worth being explicit that this could not be confirmed on macOS:
that platform ships `libdarwinmediaplugin`, which emits `EndOfMedia`
*before* `StoppedState` — the reverse ordering — so the loop restarts
before any cleanup runs and the bug cannot occur there at all. Reverting
the fix on macOS produced identical behaviour. The fix is therefore
specific to backends with the FFmpeg ordering, which is where the issue
was reported.
A functional test is included
(`test/functional_tests/TMediaLoopTest.cpp`) covering both halves of the
contract: a looping track must survive the stop/restart cycle, and a
one-shot track must still be cleaned up. It probes the backend before
asserting and **skips** unless that backend can both finish a clip and
emit `StoppedState` before `EndOfMedia`. Without that guard the looping
assertion passes on broken code, which is exactly what happened on
macOS. Expected results:
- FFmpeg backend → runs and asserts
- macOS darwin backend → skips (cannot exhibit the bug)
- Anywhere media stalls under `QT_QPA_PLATFORM=offscreen`, which the
functional suite forces → skips
So it may well skip in CI; that is intentional and preferable to a test
that always passes.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-02 13:11:37 -04:00
void test_restartedTrackKeepsItsNewSource ( )
{
Fix: Sounds going silent when a file fails to load (#9612)
#### Brief overview of PR changes/additions
Follow-up hardening on top of #9569. Two ways a media player could end
up silent while still holding its source, plus the crash and the test
gaps found chasing them.
**A track that fails to load.** Nothing in `TMedia` listened for
`QMediaPlayer::errorOccurred`. A player that was already stopped when
its source failed reports no playback state change — and that signal is
what ends a playback, releases the source and raises `sysMediaFinished`.
The track fell silent holding a file nothing would ever release. The
error is now acted on.
**A track stopped while it is still loading.** Qt already considers an
unstarted player stopped, so `stop()` draws no state change out of one
mid-load, with the same result. This is not a narrow race: on an
asynchronous backend the Linux and Windows runners hit it every time.
`stopMedia()` now ends such a playback itself instead of waiting for a
report that is never coming.
Around those:
- The deferred source release is a single function shared by the stop,
error and teardown paths. Whether the player's own state gets a say
differs between them, so callers pass a `PlaybackEnd`: a stop needs it,
because a restart may be in flight and a player loading its next source
looks identical to a stopped one; a failure must *not* have it, because
a backend can report `PlayingState` for media it has just failed to
load.
- Re-sourcing a player goes through `claimSource()`, `continuePlaying()`
and `releaseSource()`, so the generation bump that tells a pending
release the track has moved on cannot be forgotten at a call site. A
missed bump is what let an earlier revision of #9569 clear the source of
a track that had just been restarted.
- `stopMedia()` empties the playlist, so an explicit stop cannot leave a
loop armed to restart itself from the `EndOfMedia` handler.
- `setupVideo()` failing now releases the source it claimed, and hides
the video widget only under the same `mediaWidget`/`mediaClose` guards
the deferred release uses — previously it could hide a label belonging
to an earlier clip.
- `src/dlgTriggerEditor.cpp` is here for one guard:
`runScheduledCleanReset()` repopulates itself from a `Host` that a
profile teardown has already destroyed. Unrelated to media, but it
crashed the media tests once they started running.
Behaviour worth knowing about when reviewing:
- `sysMediaFinished` now fires for a failed load and for a stop issued
mid-load, where nothing fired before. It is suppressed when the source
has already been released, where it would only have carried an empty
file name and path.
- `purgeMediaCache()` returns `false` when the directory could not be
fully removed, instead of always returning `true`.
- The closing closed caption is suppressed between the passes of a
looping track, and printed by `stopAllMediaPlayers()`, which releases
synchronously.
- `TMedia` gains three read-only diagnostics used by the tests —
`playersHoldingSource()`, `mediaPlayerCount()` and
`playersInPlayingState()`. A deferred release is otherwise unobservable:
`playingMedia()` has already dropped the player, the caption needs
captions enabled, and the video signal needs a widget.
Tests: new slots for a finite `loops=N` track, an explicit stop, an
unplayable source and a reused player. `probeBackend()` measures what
the backend can demonstrate — whether it starts playback at all, decodes
to `EndOfMedia`, orders `EndOfMedia` against `StoppedState`, starts
synchronously, and reports an undecodable file — and each test skips on
the capabilities it needs, printing what was measured.
`QT_MEDIA_BACKEND` is pinned to whatever `main.cpp` ships per platform,
since `QTEST_MAIN` does not run `main.cpp` and the tests were otherwise
exercising Qt's default backend rather than the one users get.
#### Motivation for adding to Mudlet
Both silent-failure cases are real and user-visible. A game sending a
filename Qt cannot decode, or a file gone from the media cache, would
kill the sound and leak the player's source while reporting nothing; and
`stopMusic()` shortly after `playMusic()` would leak the source every
time on Windows and Linux. A script chaining tracks off
`sysMediaFinished` waited forever in both cases.
They share a root cause with #9566: the deferred release could not tell
what the player was doing when its turn came around. The
`claimSource()`/`continuePlaying()`/`releaseSource()` encapsulation is
the durable part — it turns "remember to bump the counter" from a
convention into something the API does for you.
The test work matters as much as the fixes. The #9566 regression guard
was skipping on every CI job, so it was protecting nothing; both bugs
above were caught only once it actually ran.
#### Other info (issues closed, discussion etc)
Follow-up to #9569 / #9566. No issue number of its own.
Testing notes: the full functional suite passes — 66/66 on Linux, and
the media suite is green on the Ubuntu, Windows and both macOS jobs.
Backends differ in what they can demonstrate, so some media tests skip
by design. Under the environment ctest uses, six of the seven skip on
macOS: the `darwin` backend starts playback synchronously and delivers
`EndOfMedia` before `StoppedState`, so it can stage neither the claim
race nor the #9566 ordering, and it does not decode under
`QT_QPA_PLATFORM=offscreen`. Those paths run on the Ubuntu and Windows
FFmpeg builds, which is where both bugs in this PR were caught. Every
skip prints what the backend could not demonstrate and why, so an inert
guard is visible rather than silent.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-05 00:34:43 -04:00
SKIP_OR_FAIL_WITHOUT ( mCannotStartReason ) ;
SKIP_OR_FAIL_WITHOUT ( mSynchronousStartReason ) ;
Fix looping sounds playing only once (#9569)
#### Brief overview of PR changes/additions
Looping media (`loops=-1`) plays only once on Qt's FFmpeg multimedia
backend.
That backend ends a track by emitting `StoppedState` **first** and
`EndOfMedia` **second**, and it skips the `EndOfMedia` notification when
the playback engine was destroyed in between:
```cpp
if (currentPlaybackEngine)
stateChanged(QMediaPlayer::StoppedState);
if (currentPlaybackEngine)
mediaStatusChanged(QMediaPlayer::EndOfMedia);
```
Mudlet restarts a loop from its `EndOfMedia` handler, but
`TMedia::handlePlayerPlaybackStateChanged()` called `setSource(QUrl())`
immediately on `StoppedState`. That destroyed the engine, so
`EndOfMedia` never arrived and the loop died after one pass.
This defers the cleanup by one event-loop turn and re-checks the
playback state, so a loop restart can claim the player first while a
genuinely stopped player is still torn down.
#### Motivation for adding to Mudlet
Fixes #9566. Ambient/background music tracks stop after a single pass,
which affects any game using `Client.Media.Play` with `loops: -1`, MSP,
or `playMusicFile()`.
The immediate source clearing was introduced by #9237 as a
resource-cleanup measure. That cleanup is preserved here — it just runs
one event-loop turn later, and only if the player is still stopped.
#### Other info (issues closed, discussion etc)
Fixes #9566. Regression introduced by #9237 (merged 2026-04-29), so 4.21
and 4.22 are affected; 4.20.1 and earlier are not.
**Verified on Windows against the FFmpeg backend.** The bug reproduces
on unfixed code there, and this change resolves it without regressing
the cleanup that #9237 added.
It is worth being explicit that this could not be confirmed on macOS:
that platform ships `libdarwinmediaplugin`, which emits `EndOfMedia`
*before* `StoppedState` — the reverse ordering — so the loop restarts
before any cleanup runs and the bug cannot occur there at all. Reverting
the fix on macOS produced identical behaviour. The fix is therefore
specific to backends with the FFmpeg ordering, which is where the issue
was reported.
A functional test is included
(`test/functional_tests/TMediaLoopTest.cpp`) covering both halves of the
contract: a looping track must survive the stop/restart cycle, and a
one-shot track must still be cleaned up. It probes the backend before
asserting and **skips** unless that backend can both finish a clip and
emit `StoppedState` before `EndOfMedia`. Without that guard the looping
assertion passes on broken code, which is exactly what happened on
macOS. Expected results:
- FFmpeg backend → runs and asserts
- macOS darwin backend → skips (cannot exhibit the bug)
- Anywhere media stalls under `QT_QPA_PLATFORM=offscreen`, which the
functional suite forces → skips
So it may well skip in CI; that is intentional and preferable to a test
that always passes.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-02 13:11:37 -04:00
auto * media = startProfileAndGetMedia ( ) ;
QVERIFY ( media ) ;
const QString fileName = writeClip ( qsl ( " restart.wav " ) ) ;
Fix: Sounds going silent when a file fails to load (#9612)
#### Brief overview of PR changes/additions
Follow-up hardening on top of #9569. Two ways a media player could end
up silent while still holding its source, plus the crash and the test
gaps found chasing them.
**A track that fails to load.** Nothing in `TMedia` listened for
`QMediaPlayer::errorOccurred`. A player that was already stopped when
its source failed reports no playback state change — and that signal is
what ends a playback, releases the source and raises `sysMediaFinished`.
The track fell silent holding a file nothing would ever release. The
error is now acted on.
**A track stopped while it is still loading.** Qt already considers an
unstarted player stopped, so `stop()` draws no state change out of one
mid-load, with the same result. This is not a narrow race: on an
asynchronous backend the Linux and Windows runners hit it every time.
`stopMedia()` now ends such a playback itself instead of waiting for a
report that is never coming.
Around those:
- The deferred source release is a single function shared by the stop,
error and teardown paths. Whether the player's own state gets a say
differs between them, so callers pass a `PlaybackEnd`: a stop needs it,
because a restart may be in flight and a player loading its next source
looks identical to a stopped one; a failure must *not* have it, because
a backend can report `PlayingState` for media it has just failed to
load.
- Re-sourcing a player goes through `claimSource()`, `continuePlaying()`
and `releaseSource()`, so the generation bump that tells a pending
release the track has moved on cannot be forgotten at a call site. A
missed bump is what let an earlier revision of #9569 clear the source of
a track that had just been restarted.
- `stopMedia()` empties the playlist, so an explicit stop cannot leave a
loop armed to restart itself from the `EndOfMedia` handler.
- `setupVideo()` failing now releases the source it claimed, and hides
the video widget only under the same `mediaWidget`/`mediaClose` guards
the deferred release uses — previously it could hide a label belonging
to an earlier clip.
- `src/dlgTriggerEditor.cpp` is here for one guard:
`runScheduledCleanReset()` repopulates itself from a `Host` that a
profile teardown has already destroyed. Unrelated to media, but it
crashed the media tests once they started running.
Behaviour worth knowing about when reviewing:
- `sysMediaFinished` now fires for a failed load and for a stop issued
mid-load, where nothing fired before. It is suppressed when the source
has already been released, where it would only have carried an empty
file name and path.
- `purgeMediaCache()` returns `false` when the directory could not be
fully removed, instead of always returning `true`.
- The closing closed caption is suppressed between the passes of a
looping track, and printed by `stopAllMediaPlayers()`, which releases
synchronously.
- `TMedia` gains three read-only diagnostics used by the tests —
`playersHoldingSource()`, `mediaPlayerCount()` and
`playersInPlayingState()`. A deferred release is otherwise unobservable:
`playingMedia()` has already dropped the player, the caption needs
captions enabled, and the video signal needs a widget.
Tests: new slots for a finite `loops=N` track, an explicit stop, an
unplayable source and a reused player. `probeBackend()` measures what
the backend can demonstrate — whether it starts playback at all, decodes
to `EndOfMedia`, orders `EndOfMedia` against `StoppedState`, starts
synchronously, and reports an undecodable file — and each test skips on
the capabilities it needs, printing what was measured.
`QT_MEDIA_BACKEND` is pinned to whatever `main.cpp` ships per platform,
since `QTEST_MAIN` does not run `main.cpp` and the tests were otherwise
exercising Qt's default backend rather than the one users get.
#### Motivation for adding to Mudlet
Both silent-failure cases are real and user-visible. A game sending a
filename Qt cannot decode, or a file gone from the media cache, would
kill the sound and leak the player's source while reporting nothing; and
`stopMusic()` shortly after `playMusic()` would leak the source every
time on Windows and Linux. A script chaining tracks off
`sysMediaFinished` waited forever in both cases.
They share a root cause with #9566: the deferred release could not tell
what the player was doing when its turn came around. The
`claimSource()`/`continuePlaying()`/`releaseSource()` encapsulation is
the durable part — it turns "remember to bump the counter" from a
convention into something the API does for you.
The test work matters as much as the fixes. The #9566 regression guard
was skipping on every CI job, so it was protecting nothing; both bugs
above were caught only once it actually ran.
#### Other info (issues closed, discussion etc)
Follow-up to #9569 / #9566. No issue number of its own.
Testing notes: the full functional suite passes — 66/66 on Linux, and
the media suite is green on the Ubuntu, Windows and both macOS jobs.
Backends differ in what they can demonstrate, so some media tests skip
by design. Under the environment ctest uses, six of the seven skip on
macOS: the `darwin` backend starts playback synchronously and delivers
`EndOfMedia` before `StoppedState`, so it can stage neither the claim
race nor the #9566 ordering, and it does not decode under
`QT_QPA_PLATFORM=offscreen`. Those paths run on the Ubuntu and Windows
FFmpeg builds, which is where both bugs in this PR were caught. Every
skip prints what the backend could not demonstrate and why, so an inert
guard is visible rather than silent.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-05 00:34:43 -04:00
QVERIFY ( ! fileName . isEmpty ( ) ) ;
Fix looping sounds playing only once (#9569)
#### Brief overview of PR changes/additions
Looping media (`loops=-1`) plays only once on Qt's FFmpeg multimedia
backend.
That backend ends a track by emitting `StoppedState` **first** and
`EndOfMedia` **second**, and it skips the `EndOfMedia` notification when
the playback engine was destroyed in between:
```cpp
if (currentPlaybackEngine)
stateChanged(QMediaPlayer::StoppedState);
if (currentPlaybackEngine)
mediaStatusChanged(QMediaPlayer::EndOfMedia);
```
Mudlet restarts a loop from its `EndOfMedia` handler, but
`TMedia::handlePlayerPlaybackStateChanged()` called `setSource(QUrl())`
immediately on `StoppedState`. That destroyed the engine, so
`EndOfMedia` never arrived and the loop died after one pass.
This defers the cleanup by one event-loop turn and re-checks the
playback state, so a loop restart can claim the player first while a
genuinely stopped player is still torn down.
#### Motivation for adding to Mudlet
Fixes #9566. Ambient/background music tracks stop after a single pass,
which affects any game using `Client.Media.Play` with `loops: -1`, MSP,
or `playMusicFile()`.
The immediate source clearing was introduced by #9237 as a
resource-cleanup measure. That cleanup is preserved here — it just runs
one event-loop turn later, and only if the player is still stopped.
#### Other info (issues closed, discussion etc)
Fixes #9566. Regression introduced by #9237 (merged 2026-04-29), so 4.21
and 4.22 are affected; 4.20.1 and earlier are not.
**Verified on Windows against the FFmpeg backend.** The bug reproduces
on unfixed code there, and this change resolves it without regressing
the cleanup that #9237 added.
It is worth being explicit that this could not be confirmed on macOS:
that platform ships `libdarwinmediaplugin`, which emits `EndOfMedia`
*before* `StoppedState` — the reverse ordering — so the loop restarts
before any cleanup runs and the bug cannot occur there at all. Reverting
the fix on macOS produced identical behaviour. The fix is therefore
specific to backends with the FFmpeg ordering, which is where the issue
was reported.
A functional test is included
(`test/functional_tests/TMediaLoopTest.cpp`) covering both halves of the
contract: a looping track must survive the stop/restart cycle, and a
one-shot track must still be cleaned up. It probes the backend before
asserting and **skips** unless that backend can both finish a clip and
emit `StoppedState` before `EndOfMedia`. Without that guard the looping
assertion passes on broken code, which is exactly what happened on
macOS. Expected results:
- FFmpeg backend → runs and asserts
- macOS darwin backend → skips (cannot exhibit the bug)
- Anywhere media stalls under `QT_QPA_PLATFORM=offscreen`, which the
functional suite forces → skips
So it may well skip in CI; that is intentional and preferable to a test
that always passes.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-02 13:11:37 -04:00
TMediaData data = clipData ( fileName ) ;
data . setMediaLoops ( TMediaData : : MediaLoopsRepeat ) ;
media - > playMedia ( data ) ;
Fix: Sounds going silent when a file fails to load (#9612)
#### Brief overview of PR changes/additions
Follow-up hardening on top of #9569. Two ways a media player could end
up silent while still holding its source, plus the crash and the test
gaps found chasing them.
**A track that fails to load.** Nothing in `TMedia` listened for
`QMediaPlayer::errorOccurred`. A player that was already stopped when
its source failed reports no playback state change — and that signal is
what ends a playback, releases the source and raises `sysMediaFinished`.
The track fell silent holding a file nothing would ever release. The
error is now acted on.
**A track stopped while it is still loading.** Qt already considers an
unstarted player stopped, so `stop()` draws no state change out of one
mid-load, with the same result. This is not a narrow race: on an
asynchronous backend the Linux and Windows runners hit it every time.
`stopMedia()` now ends such a playback itself instead of waiting for a
report that is never coming.
Around those:
- The deferred source release is a single function shared by the stop,
error and teardown paths. Whether the player's own state gets a say
differs between them, so callers pass a `PlaybackEnd`: a stop needs it,
because a restart may be in flight and a player loading its next source
looks identical to a stopped one; a failure must *not* have it, because
a backend can report `PlayingState` for media it has just failed to
load.
- Re-sourcing a player goes through `claimSource()`, `continuePlaying()`
and `releaseSource()`, so the generation bump that tells a pending
release the track has moved on cannot be forgotten at a call site. A
missed bump is what let an earlier revision of #9569 clear the source of
a track that had just been restarted.
- `stopMedia()` empties the playlist, so an explicit stop cannot leave a
loop armed to restart itself from the `EndOfMedia` handler.
- `setupVideo()` failing now releases the source it claimed, and hides
the video widget only under the same `mediaWidget`/`mediaClose` guards
the deferred release uses — previously it could hide a label belonging
to an earlier clip.
- `src/dlgTriggerEditor.cpp` is here for one guard:
`runScheduledCleanReset()` repopulates itself from a `Host` that a
profile teardown has already destroyed. Unrelated to media, but it
crashed the media tests once they started running.
Behaviour worth knowing about when reviewing:
- `sysMediaFinished` now fires for a failed load and for a stop issued
mid-load, where nothing fired before. It is suppressed when the source
has already been released, where it would only have carried an empty
file name and path.
- `purgeMediaCache()` returns `false` when the directory could not be
fully removed, instead of always returning `true`.
- The closing closed caption is suppressed between the passes of a
looping track, and printed by `stopAllMediaPlayers()`, which releases
synchronously.
- `TMedia` gains three read-only diagnostics used by the tests —
`playersHoldingSource()`, `mediaPlayerCount()` and
`playersInPlayingState()`. A deferred release is otherwise unobservable:
`playingMedia()` has already dropped the player, the caption needs
captions enabled, and the video signal needs a widget.
Tests: new slots for a finite `loops=N` track, an explicit stop, an
unplayable source and a reused player. `probeBackend()` measures what
the backend can demonstrate — whether it starts playback at all, decodes
to `EndOfMedia`, orders `EndOfMedia` against `StoppedState`, starts
synchronously, and reports an undecodable file — and each test skips on
the capabilities it needs, printing what was measured.
`QT_MEDIA_BACKEND` is pinned to whatever `main.cpp` ships per platform,
since `QTEST_MAIN` does not run `main.cpp` and the tests were otherwise
exercising Qt's default backend rather than the one users get.
#### Motivation for adding to Mudlet
Both silent-failure cases are real and user-visible. A game sending a
filename Qt cannot decode, or a file gone from the media cache, would
kill the sound and leak the player's source while reporting nothing; and
`stopMusic()` shortly after `playMusic()` would leak the source every
time on Windows and Linux. A script chaining tracks off
`sysMediaFinished` waited forever in both cases.
They share a root cause with #9566: the deferred release could not tell
what the player was doing when its turn came around. The
`claimSource()`/`continuePlaying()`/`releaseSource()` encapsulation is
the durable part — it turns "remember to bump the counter" from a
convention into something the API does for you.
The test work matters as much as the fixes. The #9566 regression guard
was skipping on every CI job, so it was protecting nothing; both bugs
above were caught only once it actually ran.
#### Other info (issues closed, discussion etc)
Follow-up to #9569 / #9566. No issue number of its own.
Testing notes: the full functional suite passes — 66/66 on Linux, and
the media suite is green on the Ubuntu, Windows and both macOS jobs.
Backends differ in what they can demonstrate, so some media tests skip
by design. Under the environment ctest uses, six of the seven skip on
macOS: the `darwin` backend starts playback synchronously and delivers
`EndOfMedia` before `StoppedState`, so it can stage neither the claim
race nor the #9566 ordering, and it does not decode under
`QT_QPA_PLATFORM=offscreen`. Those paths run on the Ubuntu and Windows
FFmpeg builds, which is where both bugs in this PR were caught. Every
skip prints what the backend could not demonstrate and why, so an inert
guard is visible rather than silent.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-05 00:34:43 -04:00
QVERIFY2 ( waitForPlaybackStarted ( media , fileName ) , " The track never started playing. " ) ;
Fix looping sounds playing only once (#9569)
#### Brief overview of PR changes/additions
Looping media (`loops=-1`) plays only once on Qt's FFmpeg multimedia
backend.
That backend ends a track by emitting `StoppedState` **first** and
`EndOfMedia` **second**, and it skips the `EndOfMedia` notification when
the playback engine was destroyed in between:
```cpp
if (currentPlaybackEngine)
stateChanged(QMediaPlayer::StoppedState);
if (currentPlaybackEngine)
mediaStatusChanged(QMediaPlayer::EndOfMedia);
```
Mudlet restarts a loop from its `EndOfMedia` handler, but
`TMedia::handlePlayerPlaybackStateChanged()` called `setSource(QUrl())`
immediately on `StoppedState`. That destroyed the engine, so
`EndOfMedia` never arrived and the loop died after one pass.
This defers the cleanup by one event-loop turn and re-checks the
playback state, so a loop restart can claim the player first while a
genuinely stopped player is still torn down.
#### Motivation for adding to Mudlet
Fixes #9566. Ambient/background music tracks stop after a single pass,
which affects any game using `Client.Media.Play` with `loops: -1`, MSP,
or `playMusicFile()`.
The immediate source clearing was introduced by #9237 as a
resource-cleanup measure. That cleanup is preserved here — it just runs
one event-loop turn later, and only if the player is still stopped.
#### Other info (issues closed, discussion etc)
Fixes #9566. Regression introduced by #9237 (merged 2026-04-29), so 4.21
and 4.22 are affected; 4.20.1 and earlier are not.
**Verified on Windows against the FFmpeg backend.** The bug reproduces
on unfixed code there, and this change resolves it without regressing
the cleanup that #9237 added.
It is worth being explicit that this could not be confirmed on macOS:
that platform ships `libdarwinmediaplugin`, which emits `EndOfMedia`
*before* `StoppedState` — the reverse ordering — so the loop restarts
before any cleanup runs and the bug cannot occur there at all. Reverting
the fix on macOS produced identical behaviour. The fix is therefore
specific to backends with the FFmpeg ordering, which is where the issue
was reported.
A functional test is included
(`test/functional_tests/TMediaLoopTest.cpp`) covering both halves of the
contract: a looping track must survive the stop/restart cycle, and a
one-shot track must still be cleaned up. It probes the backend before
asserting and **skips** unless that backend can both finish a clip and
emit `StoppedState` before `EndOfMedia`. Without that guard the looping
assertion passes on broken code, which is exactly what happened on
macOS. Expected results:
- FFmpeg backend → runs and asserts
- macOS darwin backend → skips (cannot exhibit the bug)
- Anywhere media stalls under `QT_QPA_PLATFORM=offscreen`, which the
functional suite forces → skips
So it may well skip in CI; that is intentional and preferable to a test
that always passes.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-02 13:11:37 -04:00
TMediaData restart = clipData ( fileName ) ;
restart . setMediaLoops ( TMediaData : : MediaLoopsRepeat ) ;
restart . setMediaContinue ( TMediaData : : MediaContinueRestart ) ;
media - > playMedia ( restart ) ;
// Past the turn the stop inside that restart scheduled its cleanup for.
QTest : : qWait ( clipMs ) ;
Fix: Sounds going silent when a file fails to load (#9612)
#### Brief overview of PR changes/additions
Follow-up hardening on top of #9569. Two ways a media player could end
up silent while still holding its source, plus the crash and the test
gaps found chasing them.
**A track that fails to load.** Nothing in `TMedia` listened for
`QMediaPlayer::errorOccurred`. A player that was already stopped when
its source failed reports no playback state change — and that signal is
what ends a playback, releases the source and raises `sysMediaFinished`.
The track fell silent holding a file nothing would ever release. The
error is now acted on.
**A track stopped while it is still loading.** Qt already considers an
unstarted player stopped, so `stop()` draws no state change out of one
mid-load, with the same result. This is not a narrow race: on an
asynchronous backend the Linux and Windows runners hit it every time.
`stopMedia()` now ends such a playback itself instead of waiting for a
report that is never coming.
Around those:
- The deferred source release is a single function shared by the stop,
error and teardown paths. Whether the player's own state gets a say
differs between them, so callers pass a `PlaybackEnd`: a stop needs it,
because a restart may be in flight and a player loading its next source
looks identical to a stopped one; a failure must *not* have it, because
a backend can report `PlayingState` for media it has just failed to
load.
- Re-sourcing a player goes through `claimSource()`, `continuePlaying()`
and `releaseSource()`, so the generation bump that tells a pending
release the track has moved on cannot be forgotten at a call site. A
missed bump is what let an earlier revision of #9569 clear the source of
a track that had just been restarted.
- `stopMedia()` empties the playlist, so an explicit stop cannot leave a
loop armed to restart itself from the `EndOfMedia` handler.
- `setupVideo()` failing now releases the source it claimed, and hides
the video widget only under the same `mediaWidget`/`mediaClose` guards
the deferred release uses — previously it could hide a label belonging
to an earlier clip.
- `src/dlgTriggerEditor.cpp` is here for one guard:
`runScheduledCleanReset()` repopulates itself from a `Host` that a
profile teardown has already destroyed. Unrelated to media, but it
crashed the media tests once they started running.
Behaviour worth knowing about when reviewing:
- `sysMediaFinished` now fires for a failed load and for a stop issued
mid-load, where nothing fired before. It is suppressed when the source
has already been released, where it would only have carried an empty
file name and path.
- `purgeMediaCache()` returns `false` when the directory could not be
fully removed, instead of always returning `true`.
- The closing closed caption is suppressed between the passes of a
looping track, and printed by `stopAllMediaPlayers()`, which releases
synchronously.
- `TMedia` gains three read-only diagnostics used by the tests —
`playersHoldingSource()`, `mediaPlayerCount()` and
`playersInPlayingState()`. A deferred release is otherwise unobservable:
`playingMedia()` has already dropped the player, the caption needs
captions enabled, and the video signal needs a widget.
Tests: new slots for a finite `loops=N` track, an explicit stop, an
unplayable source and a reused player. `probeBackend()` measures what
the backend can demonstrate — whether it starts playback at all, decodes
to `EndOfMedia`, orders `EndOfMedia` against `StoppedState`, starts
synchronously, and reports an undecodable file — and each test skips on
the capabilities it needs, printing what was measured.
`QT_MEDIA_BACKEND` is pinned to whatever `main.cpp` ships per platform,
since `QTEST_MAIN` does not run `main.cpp` and the tests were otherwise
exercising Qt's default backend rather than the one users get.
#### Motivation for adding to Mudlet
Both silent-failure cases are real and user-visible. A game sending a
filename Qt cannot decode, or a file gone from the media cache, would
kill the sound and leak the player's source while reporting nothing; and
`stopMusic()` shortly after `playMusic()` would leak the source every
time on Windows and Linux. A script chaining tracks off
`sysMediaFinished` waited forever in both cases.
They share a root cause with #9566: the deferred release could not tell
what the player was doing when its turn came around. The
`claimSource()`/`continuePlaying()`/`releaseSource()` encapsulation is
the durable part — it turns "remember to bump the counter" from a
convention into something the API does for you.
The test work matters as much as the fixes. The #9566 regression guard
was skipping on every CI job, so it was protecting nothing; both bugs
above were caught only once it actually ran.
#### Other info (issues closed, discussion etc)
Follow-up to #9569 / #9566. No issue number of its own.
Testing notes: the full functional suite passes — 66/66 on Linux, and
the media suite is green on the Ubuntu, Windows and both macOS jobs.
Backends differ in what they can demonstrate, so some media tests skip
by design. Under the environment ctest uses, six of the seven skip on
macOS: the `darwin` backend starts playback synchronously and delivers
`EndOfMedia` before `StoppedState`, so it can stage neither the claim
race nor the #9566 ordering, and it does not decode under
`QT_QPA_PLATFORM=offscreen`. Those paths run on the Ubuntu and Windows
FFmpeg builds, which is where both bugs in this PR were caught. Every
skip prints what the backend could not demonstrate and why, so an inert
guard is visible rather than silent.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-05 00:34:43 -04:00
QVERIFY2 ( waitForPlaying ( media , fileName , 2 s ) , " A restarted track was cut off - the cleanup deferred by its own stop cleared the source it had just been given. " ) ;
Fix looping sounds playing only once (#9569)
#### Brief overview of PR changes/additions
Looping media (`loops=-1`) plays only once on Qt's FFmpeg multimedia
backend.
That backend ends a track by emitting `StoppedState` **first** and
`EndOfMedia` **second**, and it skips the `EndOfMedia` notification when
the playback engine was destroyed in between:
```cpp
if (currentPlaybackEngine)
stateChanged(QMediaPlayer::StoppedState);
if (currentPlaybackEngine)
mediaStatusChanged(QMediaPlayer::EndOfMedia);
```
Mudlet restarts a loop from its `EndOfMedia` handler, but
`TMedia::handlePlayerPlaybackStateChanged()` called `setSource(QUrl())`
immediately on `StoppedState`. That destroyed the engine, so
`EndOfMedia` never arrived and the loop died after one pass.
This defers the cleanup by one event-loop turn and re-checks the
playback state, so a loop restart can claim the player first while a
genuinely stopped player is still torn down.
#### Motivation for adding to Mudlet
Fixes #9566. Ambient/background music tracks stop after a single pass,
which affects any game using `Client.Media.Play` with `loops: -1`, MSP,
or `playMusicFile()`.
The immediate source clearing was introduced by #9237 as a
resource-cleanup measure. That cleanup is preserved here — it just runs
one event-loop turn later, and only if the player is still stopped.
#### Other info (issues closed, discussion etc)
Fixes #9566. Regression introduced by #9237 (merged 2026-04-29), so 4.21
and 4.22 are affected; 4.20.1 and earlier are not.
**Verified on Windows against the FFmpeg backend.** The bug reproduces
on unfixed code there, and this change resolves it without regressing
the cleanup that #9237 added.
It is worth being explicit that this could not be confirmed on macOS:
that platform ships `libdarwinmediaplugin`, which emits `EndOfMedia`
*before* `StoppedState` — the reverse ordering — so the loop restarts
before any cleanup runs and the bug cannot occur there at all. Reverting
the fix on macOS produced identical behaviour. The fix is therefore
specific to backends with the FFmpeg ordering, which is where the issue
was reported.
A functional test is included
(`test/functional_tests/TMediaLoopTest.cpp`) covering both halves of the
contract: a looping track must survive the stop/restart cycle, and a
one-shot track must still be cleaned up. It probes the backend before
asserting and **skips** unless that backend can both finish a clip and
emit `StoppedState` before `EndOfMedia`. Without that guard the looping
assertion passes on broken code, which is exactly what happened on
macOS. Expected results:
- FFmpeg backend → runs and asserts
- macOS darwin backend → skips (cannot exhibit the bug)
- Anywhere media stalls under `QT_QPA_PLATFORM=offscreen`, which the
functional suite forces → skips
So it may well skip in CI; that is intentional and preferable to a test
that always passes.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-02 13:11:37 -04:00
}
void cleanup ( )
{
delete mpServer ;
mpServer = nullptr ;
Fix: Sounds going silent when a file fails to load (#9612)
#### Brief overview of PR changes/additions
Follow-up hardening on top of #9569. Two ways a media player could end
up silent while still holding its source, plus the crash and the test
gaps found chasing them.
**A track that fails to load.** Nothing in `TMedia` listened for
`QMediaPlayer::errorOccurred`. A player that was already stopped when
its source failed reports no playback state change — and that signal is
what ends a playback, releases the source and raises `sysMediaFinished`.
The track fell silent holding a file nothing would ever release. The
error is now acted on.
**A track stopped while it is still loading.** Qt already considers an
unstarted player stopped, so `stop()` draws no state change out of one
mid-load, with the same result. This is not a narrow race: on an
asynchronous backend the Linux and Windows runners hit it every time.
`stopMedia()` now ends such a playback itself instead of waiting for a
report that is never coming.
Around those:
- The deferred source release is a single function shared by the stop,
error and teardown paths. Whether the player's own state gets a say
differs between them, so callers pass a `PlaybackEnd`: a stop needs it,
because a restart may be in flight and a player loading its next source
looks identical to a stopped one; a failure must *not* have it, because
a backend can report `PlayingState` for media it has just failed to
load.
- Re-sourcing a player goes through `claimSource()`, `continuePlaying()`
and `releaseSource()`, so the generation bump that tells a pending
release the track has moved on cannot be forgotten at a call site. A
missed bump is what let an earlier revision of #9569 clear the source of
a track that had just been restarted.
- `stopMedia()` empties the playlist, so an explicit stop cannot leave a
loop armed to restart itself from the `EndOfMedia` handler.
- `setupVideo()` failing now releases the source it claimed, and hides
the video widget only under the same `mediaWidget`/`mediaClose` guards
the deferred release uses — previously it could hide a label belonging
to an earlier clip.
- `src/dlgTriggerEditor.cpp` is here for one guard:
`runScheduledCleanReset()` repopulates itself from a `Host` that a
profile teardown has already destroyed. Unrelated to media, but it
crashed the media tests once they started running.
Behaviour worth knowing about when reviewing:
- `sysMediaFinished` now fires for a failed load and for a stop issued
mid-load, where nothing fired before. It is suppressed when the source
has already been released, where it would only have carried an empty
file name and path.
- `purgeMediaCache()` returns `false` when the directory could not be
fully removed, instead of always returning `true`.
- The closing closed caption is suppressed between the passes of a
looping track, and printed by `stopAllMediaPlayers()`, which releases
synchronously.
- `TMedia` gains three read-only diagnostics used by the tests —
`playersHoldingSource()`, `mediaPlayerCount()` and
`playersInPlayingState()`. A deferred release is otherwise unobservable:
`playingMedia()` has already dropped the player, the caption needs
captions enabled, and the video signal needs a widget.
Tests: new slots for a finite `loops=N` track, an explicit stop, an
unplayable source and a reused player. `probeBackend()` measures what
the backend can demonstrate — whether it starts playback at all, decodes
to `EndOfMedia`, orders `EndOfMedia` against `StoppedState`, starts
synchronously, and reports an undecodable file — and each test skips on
the capabilities it needs, printing what was measured.
`QT_MEDIA_BACKEND` is pinned to whatever `main.cpp` ships per platform,
since `QTEST_MAIN` does not run `main.cpp` and the tests were otherwise
exercising Qt's default backend rather than the one users get.
#### Motivation for adding to Mudlet
Both silent-failure cases are real and user-visible. A game sending a
filename Qt cannot decode, or a file gone from the media cache, would
kill the sound and leak the player's source while reporting nothing; and
`stopMusic()` shortly after `playMusic()` would leak the source every
time on Windows and Linux. A script chaining tracks off
`sysMediaFinished` waited forever in both cases.
They share a root cause with #9566: the deferred release could not tell
what the player was doing when its turn came around. The
`claimSource()`/`continuePlaying()`/`releaseSource()` encapsulation is
the durable part — it turns "remember to bump the counter" from a
convention into something the API does for you.
The test work matters as much as the fixes. The #9566 regression guard
was skipping on every CI job, so it was protecting nothing; both bugs
above were caught only once it actually ran.
#### Other info (issues closed, discussion etc)
Follow-up to #9569 / #9566. No issue number of its own.
Testing notes: the full functional suite passes — 66/66 on Linux, and
the media suite is green on the Ubuntu, Windows and both macOS jobs.
Backends differ in what they can demonstrate, so some media tests skip
by design. Under the environment ctest uses, six of the seven skip on
macOS: the `darwin` backend starts playback synchronously and delivers
`EndOfMedia` before `StoppedState`, so it can stage neither the claim
race nor the #9566 ordering, and it does not decode under
`QT_QPA_PLATFORM=offscreen`. Those paths run on the Ubuntu and Windows
FFmpeg builds, which is where both bugs in this PR were caught. Every
skip prints what the backend could not demonstrate and why, so an inert
guard is visible rather than silent.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-05 00:34:43 -04:00
mpHost = nullptr ;
Fix looping sounds playing only once (#9569)
#### Brief overview of PR changes/additions
Looping media (`loops=-1`) plays only once on Qt's FFmpeg multimedia
backend.
That backend ends a track by emitting `StoppedState` **first** and
`EndOfMedia` **second**, and it skips the `EndOfMedia` notification when
the playback engine was destroyed in between:
```cpp
if (currentPlaybackEngine)
stateChanged(QMediaPlayer::StoppedState);
if (currentPlaybackEngine)
mediaStatusChanged(QMediaPlayer::EndOfMedia);
```
Mudlet restarts a loop from its `EndOfMedia` handler, but
`TMedia::handlePlayerPlaybackStateChanged()` called `setSource(QUrl())`
immediately on `StoppedState`. That destroyed the engine, so
`EndOfMedia` never arrived and the loop died after one pass.
This defers the cleanup by one event-loop turn and re-checks the
playback state, so a loop restart can claim the player first while a
genuinely stopped player is still torn down.
#### Motivation for adding to Mudlet
Fixes #9566. Ambient/background music tracks stop after a single pass,
which affects any game using `Client.Media.Play` with `loops: -1`, MSP,
or `playMusicFile()`.
The immediate source clearing was introduced by #9237 as a
resource-cleanup measure. That cleanup is preserved here — it just runs
one event-loop turn later, and only if the player is still stopped.
#### Other info (issues closed, discussion etc)
Fixes #9566. Regression introduced by #9237 (merged 2026-04-29), so 4.21
and 4.22 are affected; 4.20.1 and earlier are not.
**Verified on Windows against the FFmpeg backend.** The bug reproduces
on unfixed code there, and this change resolves it without regressing
the cleanup that #9237 added.
It is worth being explicit that this could not be confirmed on macOS:
that platform ships `libdarwinmediaplugin`, which emits `EndOfMedia`
*before* `StoppedState` — the reverse ordering — so the loop restarts
before any cleanup runs and the bug cannot occur there at all. Reverting
the fix on macOS produced identical behaviour. The fix is therefore
specific to backends with the FFmpeg ordering, which is where the issue
was reported.
A functional test is included
(`test/functional_tests/TMediaLoopTest.cpp`) covering both halves of the
contract: a looping track must survive the stop/restart cycle, and a
one-shot track must still be cleaned up. It probes the backend before
asserting and **skips** unless that backend can both finish a clip and
emit `StoppedState` before `EndOfMedia`. Without that guard the looping
assertion passes on broken code, which is exactly what happened on
macOS. Expected results:
- FFmpeg backend → runs and asserts
- macOS darwin backend → skips (cannot exhibit the bug)
- Anywhere media stalls under `QT_QPA_PLATFORM=offscreen`, which the
functional suite forces → skips
So it may well skip in CI; that is intentional and preferable to a test
that always passes.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-02 13:11:37 -04:00
deleteProfileDirectory ( mHostname ) ;
delete mudlet : : self ( ) ;
}
private :
TMedia * startProfileAndGetMedia ( )
{
startProfile ( mHostname , mLocalhost , mPort ) ;
auto host = mudlet : : self ( ) - > getActiveHost ( ) ;
if ( ! host ) {
QTest : : qFail ( " No active host available for the test. " , __FILE__ , __LINE__ ) ;
return nullptr ;
}
auto * media = host - > mpMedia . data ( ) ;
if ( ! media ) {
QTest : : qFail ( " Host has no TMedia instance. " , __FILE__ , __LINE__ ) ;
return nullptr ;
}
Fix: Sounds going silent when a file fails to load (#9612)
#### Brief overview of PR changes/additions
Follow-up hardening on top of #9569. Two ways a media player could end
up silent while still holding its source, plus the crash and the test
gaps found chasing them.
**A track that fails to load.** Nothing in `TMedia` listened for
`QMediaPlayer::errorOccurred`. A player that was already stopped when
its source failed reports no playback state change — and that signal is
what ends a playback, releases the source and raises `sysMediaFinished`.
The track fell silent holding a file nothing would ever release. The
error is now acted on.
**A track stopped while it is still loading.** Qt already considers an
unstarted player stopped, so `stop()` draws no state change out of one
mid-load, with the same result. This is not a narrow race: on an
asynchronous backend the Linux and Windows runners hit it every time.
`stopMedia()` now ends such a playback itself instead of waiting for a
report that is never coming.
Around those:
- The deferred source release is a single function shared by the stop,
error and teardown paths. Whether the player's own state gets a say
differs between them, so callers pass a `PlaybackEnd`: a stop needs it,
because a restart may be in flight and a player loading its next source
looks identical to a stopped one; a failure must *not* have it, because
a backend can report `PlayingState` for media it has just failed to
load.
- Re-sourcing a player goes through `claimSource()`, `continuePlaying()`
and `releaseSource()`, so the generation bump that tells a pending
release the track has moved on cannot be forgotten at a call site. A
missed bump is what let an earlier revision of #9569 clear the source of
a track that had just been restarted.
- `stopMedia()` empties the playlist, so an explicit stop cannot leave a
loop armed to restart itself from the `EndOfMedia` handler.
- `setupVideo()` failing now releases the source it claimed, and hides
the video widget only under the same `mediaWidget`/`mediaClose` guards
the deferred release uses — previously it could hide a label belonging
to an earlier clip.
- `src/dlgTriggerEditor.cpp` is here for one guard:
`runScheduledCleanReset()` repopulates itself from a `Host` that a
profile teardown has already destroyed. Unrelated to media, but it
crashed the media tests once they started running.
Behaviour worth knowing about when reviewing:
- `sysMediaFinished` now fires for a failed load and for a stop issued
mid-load, where nothing fired before. It is suppressed when the source
has already been released, where it would only have carried an empty
file name and path.
- `purgeMediaCache()` returns `false` when the directory could not be
fully removed, instead of always returning `true`.
- The closing closed caption is suppressed between the passes of a
looping track, and printed by `stopAllMediaPlayers()`, which releases
synchronously.
- `TMedia` gains three read-only diagnostics used by the tests —
`playersHoldingSource()`, `mediaPlayerCount()` and
`playersInPlayingState()`. A deferred release is otherwise unobservable:
`playingMedia()` has already dropped the player, the caption needs
captions enabled, and the video signal needs a widget.
Tests: new slots for a finite `loops=N` track, an explicit stop, an
unplayable source and a reused player. `probeBackend()` measures what
the backend can demonstrate — whether it starts playback at all, decodes
to `EndOfMedia`, orders `EndOfMedia` against `StoppedState`, starts
synchronously, and reports an undecodable file — and each test skips on
the capabilities it needs, printing what was measured.
`QT_MEDIA_BACKEND` is pinned to whatever `main.cpp` ships per platform,
since `QTEST_MAIN` does not run `main.cpp` and the tests were otherwise
exercising Qt's default backend rather than the one users get.
#### Motivation for adding to Mudlet
Both silent-failure cases are real and user-visible. A game sending a
filename Qt cannot decode, or a file gone from the media cache, would
kill the sound and leak the player's source while reporting nothing; and
`stopMusic()` shortly after `playMusic()` would leak the source every
time on Windows and Linux. A script chaining tracks off
`sysMediaFinished` waited forever in both cases.
They share a root cause with #9566: the deferred release could not tell
what the player was doing when its turn came around. The
`claimSource()`/`continuePlaying()`/`releaseSource()` encapsulation is
the durable part — it turns "remember to bump the counter" from a
convention into something the API does for you.
The test work matters as much as the fixes. The #9566 regression guard
was skipping on every CI job, so it was protecting nothing; both bugs
above were caught only once it actually ran.
#### Other info (issues closed, discussion etc)
Follow-up to #9569 / #9566. No issue number of its own.
Testing notes: the full functional suite passes — 66/66 on Linux, and
the media suite is green on the Ubuntu, Windows and both macOS jobs.
Backends differ in what they can demonstrate, so some media tests skip
by design. Under the environment ctest uses, six of the seven skip on
macOS: the `darwin` backend starts playback synchronously and delivers
`EndOfMedia` before `StoppedState`, so it can stage neither the claim
race nor the #9566 ordering, and it does not decode under
`QT_QPA_PLATFORM=offscreen`. Those paths run on the Ubuntu and Windows
FFmpeg builds, which is where both bugs in this PR were caught. Every
skip prints what the backend could not demonstrate and why, so an inert
guard is visible rather than silent.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-05 00:34:43 -04:00
mpHost = host ;
Fix looping sounds playing only once (#9569)
#### Brief overview of PR changes/additions
Looping media (`loops=-1`) plays only once on Qt's FFmpeg multimedia
backend.
That backend ends a track by emitting `StoppedState` **first** and
`EndOfMedia` **second**, and it skips the `EndOfMedia` notification when
the playback engine was destroyed in between:
```cpp
if (currentPlaybackEngine)
stateChanged(QMediaPlayer::StoppedState);
if (currentPlaybackEngine)
mediaStatusChanged(QMediaPlayer::EndOfMedia);
```
Mudlet restarts a loop from its `EndOfMedia` handler, but
`TMedia::handlePlayerPlaybackStateChanged()` called `setSource(QUrl())`
immediately on `StoppedState`. That destroyed the engine, so
`EndOfMedia` never arrived and the loop died after one pass.
This defers the cleanup by one event-loop turn and re-checks the
playback state, so a loop restart can claim the player first while a
genuinely stopped player is still torn down.
#### Motivation for adding to Mudlet
Fixes #9566. Ambient/background music tracks stop after a single pass,
which affects any game using `Client.Media.Play` with `loops: -1`, MSP,
or `playMusicFile()`.
The immediate source clearing was introduced by #9237 as a
resource-cleanup measure. That cleanup is preserved here — it just runs
one event-loop turn later, and only if the player is still stopped.
#### Other info (issues closed, discussion etc)
Fixes #9566. Regression introduced by #9237 (merged 2026-04-29), so 4.21
and 4.22 are affected; 4.20.1 and earlier are not.
**Verified on Windows against the FFmpeg backend.** The bug reproduces
on unfixed code there, and this change resolves it without regressing
the cleanup that #9237 added.
It is worth being explicit that this could not be confirmed on macOS:
that platform ships `libdarwinmediaplugin`, which emits `EndOfMedia`
*before* `StoppedState` — the reverse ordering — so the loop restarts
before any cleanup runs and the bug cannot occur there at all. Reverting
the fix on macOS produced identical behaviour. The fix is therefore
specific to backends with the FFmpeg ordering, which is where the issue
was reported.
A functional test is included
(`test/functional_tests/TMediaLoopTest.cpp`) covering both halves of the
contract: a looping track must survive the stop/restart cycle, and a
one-shot track must still be cleaned up. It probes the backend before
asserting and **skips** unless that backend can both finish a clip and
emit `StoppedState` before `EndOfMedia`. Without that guard the looping
assertion passes on broken code, which is exactly what happened on
macOS. Expected results:
- FFmpeg backend → runs and asserts
- macOS darwin backend → skips (cannot exhibit the bug)
- Anywhere media stalls under `QT_QPA_PLATFORM=offscreen`, which the
functional suite forces → skips
So it may well skip in CI; that is intentional and preferable to a test
that always passes.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-02 13:11:37 -04:00
return media ;
}
Fix: Sounds going silent when a file fails to load (#9612)
#### Brief overview of PR changes/additions
Follow-up hardening on top of #9569. Two ways a media player could end
up silent while still holding its source, plus the crash and the test
gaps found chasing them.
**A track that fails to load.** Nothing in `TMedia` listened for
`QMediaPlayer::errorOccurred`. A player that was already stopped when
its source failed reports no playback state change — and that signal is
what ends a playback, releases the source and raises `sysMediaFinished`.
The track fell silent holding a file nothing would ever release. The
error is now acted on.
**A track stopped while it is still loading.** Qt already considers an
unstarted player stopped, so `stop()` draws no state change out of one
mid-load, with the same result. This is not a narrow race: on an
asynchronous backend the Linux and Windows runners hit it every time.
`stopMedia()` now ends such a playback itself instead of waiting for a
report that is never coming.
Around those:
- The deferred source release is a single function shared by the stop,
error and teardown paths. Whether the player's own state gets a say
differs between them, so callers pass a `PlaybackEnd`: a stop needs it,
because a restart may be in flight and a player loading its next source
looks identical to a stopped one; a failure must *not* have it, because
a backend can report `PlayingState` for media it has just failed to
load.
- Re-sourcing a player goes through `claimSource()`, `continuePlaying()`
and `releaseSource()`, so the generation bump that tells a pending
release the track has moved on cannot be forgotten at a call site. A
missed bump is what let an earlier revision of #9569 clear the source of
a track that had just been restarted.
- `stopMedia()` empties the playlist, so an explicit stop cannot leave a
loop armed to restart itself from the `EndOfMedia` handler.
- `setupVideo()` failing now releases the source it claimed, and hides
the video widget only under the same `mediaWidget`/`mediaClose` guards
the deferred release uses — previously it could hide a label belonging
to an earlier clip.
- `src/dlgTriggerEditor.cpp` is here for one guard:
`runScheduledCleanReset()` repopulates itself from a `Host` that a
profile teardown has already destroyed. Unrelated to media, but it
crashed the media tests once they started running.
Behaviour worth knowing about when reviewing:
- `sysMediaFinished` now fires for a failed load and for a stop issued
mid-load, where nothing fired before. It is suppressed when the source
has already been released, where it would only have carried an empty
file name and path.
- `purgeMediaCache()` returns `false` when the directory could not be
fully removed, instead of always returning `true`.
- The closing closed caption is suppressed between the passes of a
looping track, and printed by `stopAllMediaPlayers()`, which releases
synchronously.
- `TMedia` gains three read-only diagnostics used by the tests —
`playersHoldingSource()`, `mediaPlayerCount()` and
`playersInPlayingState()`. A deferred release is otherwise unobservable:
`playingMedia()` has already dropped the player, the caption needs
captions enabled, and the video signal needs a widget.
Tests: new slots for a finite `loops=N` track, an explicit stop, an
unplayable source and a reused player. `probeBackend()` measures what
the backend can demonstrate — whether it starts playback at all, decodes
to `EndOfMedia`, orders `EndOfMedia` against `StoppedState`, starts
synchronously, and reports an undecodable file — and each test skips on
the capabilities it needs, printing what was measured.
`QT_MEDIA_BACKEND` is pinned to whatever `main.cpp` ships per platform,
since `QTEST_MAIN` does not run `main.cpp` and the tests were otherwise
exercising Qt's default backend rather than the one users get.
#### Motivation for adding to Mudlet
Both silent-failure cases are real and user-visible. A game sending a
filename Qt cannot decode, or a file gone from the media cache, would
kill the sound and leak the player's source while reporting nothing; and
`stopMusic()` shortly after `playMusic()` would leak the source every
time on Windows and Linux. A script chaining tracks off
`sysMediaFinished` waited forever in both cases.
They share a root cause with #9566: the deferred release could not tell
what the player was doing when its turn came around. The
`claimSource()`/`continuePlaying()`/`releaseSource()` encapsulation is
the durable part — it turns "remember to bump the counter" from a
convention into something the API does for you.
The test work matters as much as the fixes. The #9566 regression guard
was skipping on every CI job, so it was protecting nothing; both bugs
above were caught only once it actually ran.
#### Other info (issues closed, discussion etc)
Follow-up to #9569 / #9566. No issue number of its own.
Testing notes: the full functional suite passes — 66/66 on Linux, and
the media suite is green on the Ubuntu, Windows and both macOS jobs.
Backends differ in what they can demonstrate, so some media tests skip
by design. Under the environment ctest uses, six of the seven skip on
macOS: the `darwin` backend starts playback synchronously and delivers
`EndOfMedia` before `StoppedState`, so it can stage neither the claim
race nor the #9566 ordering, and it does not decode under
`QT_QPA_PLATFORM=offscreen`. Those paths run on the Ubuntu and Windows
FFmpeg builds, which is where both bugs in this PR were caught. Every
skip prints what the backend could not demonstrate and why, so an inert
guard is visible rather than silent.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-05 00:34:43 -04:00
// sysMediaFinished is half of what the fixes here are for - a track that fails to load and
// one stopped while it is still loading each used to end in silence, with a script chaining
// its next track off that event waiting forever. Releasing the source, which is all the
// tests otherwise assert on, happens either way, so nothing here would notice the event
// going missing. Counted rather than merely seen: announcing the same ended playback more
// than once is its own bug, and one of them recursed until the stack gave out.
void watchMediaFinished ( const QString & extraHandlerBody = QString ( ) )
{
if ( ! mpHost ) {
return ;
}
mpHost - > getLuaInterpreter ( ) - > compileAndExecuteScript ( qsl ( " mediaFinishedCount = 0 \n "
" mediaFinishedNames = {} \n "
" registerAnonymousEventHandler('sysMediaFinished', function(_, fileName) \n "
" mediaFinishedCount = mediaFinishedCount + 1 \n "
" mediaFinishedNames[#mediaFinishedNames + 1] = fileName \n "
" %1 \n "
" end) \n " )
. arg ( extraHandlerBody ) ) ;
}
// Runs a Lua assertion against what watchMediaFinished() recorded; compileAndExecuteScript()
// reports a raised error as false, so a failed assert() comes back here as one.
bool mediaFinishedHolds ( const QString & luaCondition ) const { return mpHost & & mpHost - > getLuaInterpreter ( ) - > compileAndExecuteScript ( qsl ( " assert(%1) " ) . arg ( luaCondition ) ) ; }
bool waitForMediaFinishedCount ( int count , std : : chrono : : milliseconds timeout = 10 s ) const
{
return QTest : : qWaitFor (
[ & ] ( ) {
return mediaFinishedHolds ( qsl ( " mediaFinishedCount == %1 " ) . arg ( count ) ) ;
} ,
QDeadlineTimer ( timeout ) ) ;
}
// Records what this backend is and is not able to demonstrate; each reason string set below
// spells out what that costs the tests reading it. Probed once, because it has to wait out a
// whole clip and the suite gives each test executable one wall-clock budget for all of its
// slots. test_stoppedTrackReleasesItsSource needs no capability and always runs.
Fix looping sounds playing only once (#9569)
#### Brief overview of PR changes/additions
Looping media (`loops=-1`) plays only once on Qt's FFmpeg multimedia
backend.
That backend ends a track by emitting `StoppedState` **first** and
`EndOfMedia` **second**, and it skips the `EndOfMedia` notification when
the playback engine was destroyed in between:
```cpp
if (currentPlaybackEngine)
stateChanged(QMediaPlayer::StoppedState);
if (currentPlaybackEngine)
mediaStatusChanged(QMediaPlayer::EndOfMedia);
```
Mudlet restarts a loop from its `EndOfMedia` handler, but
`TMedia::handlePlayerPlaybackStateChanged()` called `setSource(QUrl())`
immediately on `StoppedState`. That destroyed the engine, so
`EndOfMedia` never arrived and the loop died after one pass.
This defers the cleanup by one event-loop turn and re-checks the
playback state, so a loop restart can claim the player first while a
genuinely stopped player is still torn down.
#### Motivation for adding to Mudlet
Fixes #9566. Ambient/background music tracks stop after a single pass,
which affects any game using `Client.Media.Play` with `loops: -1`, MSP,
or `playMusicFile()`.
The immediate source clearing was introduced by #9237 as a
resource-cleanup measure. That cleanup is preserved here — it just runs
one event-loop turn later, and only if the player is still stopped.
#### Other info (issues closed, discussion etc)
Fixes #9566. Regression introduced by #9237 (merged 2026-04-29), so 4.21
and 4.22 are affected; 4.20.1 and earlier are not.
**Verified on Windows against the FFmpeg backend.** The bug reproduces
on unfixed code there, and this change resolves it without regressing
the cleanup that #9237 added.
It is worth being explicit that this could not be confirmed on macOS:
that platform ships `libdarwinmediaplugin`, which emits `EndOfMedia`
*before* `StoppedState` — the reverse ordering — so the loop restarts
before any cleanup runs and the bug cannot occur there at all. Reverting
the fix on macOS produced identical behaviour. The fix is therefore
specific to backends with the FFmpeg ordering, which is where the issue
was reported.
A functional test is included
(`test/functional_tests/TMediaLoopTest.cpp`) covering both halves of the
contract: a looping track must survive the stop/restart cycle, and a
one-shot track must still be cleaned up. It probes the backend before
asserting and **skips** unless that backend can both finish a clip and
emit `StoppedState` before `EndOfMedia`. Without that guard the looping
assertion passes on broken code, which is exactly what happened on
macOS. Expected results:
- FFmpeg backend → runs and asserts
- macOS darwin backend → skips (cannot exhibit the bug)
- Anywhere media stalls under `QT_QPA_PLATFORM=offscreen`, which the
functional suite forces → skips
So it may well skip in CI; that is intentional and preferable to a test
that always passes.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-02 13:11:37 -04:00
void probeBackend ( )
{
if ( ! mProbeDir . isValid ( ) ) {
Fix: Sounds going silent when a file fails to load (#9612)
#### Brief overview of PR changes/additions
Follow-up hardening on top of #9569. Two ways a media player could end
up silent while still holding its source, plus the crash and the test
gaps found chasing them.
**A track that fails to load.** Nothing in `TMedia` listened for
`QMediaPlayer::errorOccurred`. A player that was already stopped when
its source failed reports no playback state change — and that signal is
what ends a playback, releases the source and raises `sysMediaFinished`.
The track fell silent holding a file nothing would ever release. The
error is now acted on.
**A track stopped while it is still loading.** Qt already considers an
unstarted player stopped, so `stop()` draws no state change out of one
mid-load, with the same result. This is not a narrow race: on an
asynchronous backend the Linux and Windows runners hit it every time.
`stopMedia()` now ends such a playback itself instead of waiting for a
report that is never coming.
Around those:
- The deferred source release is a single function shared by the stop,
error and teardown paths. Whether the player's own state gets a say
differs between them, so callers pass a `PlaybackEnd`: a stop needs it,
because a restart may be in flight and a player loading its next source
looks identical to a stopped one; a failure must *not* have it, because
a backend can report `PlayingState` for media it has just failed to
load.
- Re-sourcing a player goes through `claimSource()`, `continuePlaying()`
and `releaseSource()`, so the generation bump that tells a pending
release the track has moved on cannot be forgotten at a call site. A
missed bump is what let an earlier revision of #9569 clear the source of
a track that had just been restarted.
- `stopMedia()` empties the playlist, so an explicit stop cannot leave a
loop armed to restart itself from the `EndOfMedia` handler.
- `setupVideo()` failing now releases the source it claimed, and hides
the video widget only under the same `mediaWidget`/`mediaClose` guards
the deferred release uses — previously it could hide a label belonging
to an earlier clip.
- `src/dlgTriggerEditor.cpp` is here for one guard:
`runScheduledCleanReset()` repopulates itself from a `Host` that a
profile teardown has already destroyed. Unrelated to media, but it
crashed the media tests once they started running.
Behaviour worth knowing about when reviewing:
- `sysMediaFinished` now fires for a failed load and for a stop issued
mid-load, where nothing fired before. It is suppressed when the source
has already been released, where it would only have carried an empty
file name and path.
- `purgeMediaCache()` returns `false` when the directory could not be
fully removed, instead of always returning `true`.
- The closing closed caption is suppressed between the passes of a
looping track, and printed by `stopAllMediaPlayers()`, which releases
synchronously.
- `TMedia` gains three read-only diagnostics used by the tests —
`playersHoldingSource()`, `mediaPlayerCount()` and
`playersInPlayingState()`. A deferred release is otherwise unobservable:
`playingMedia()` has already dropped the player, the caption needs
captions enabled, and the video signal needs a widget.
Tests: new slots for a finite `loops=N` track, an explicit stop, an
unplayable source and a reused player. `probeBackend()` measures what
the backend can demonstrate — whether it starts playback at all, decodes
to `EndOfMedia`, orders `EndOfMedia` against `StoppedState`, starts
synchronously, and reports an undecodable file — and each test skips on
the capabilities it needs, printing what was measured.
`QT_MEDIA_BACKEND` is pinned to whatever `main.cpp` ships per platform,
since `QTEST_MAIN` does not run `main.cpp` and the tests were otherwise
exercising Qt's default backend rather than the one users get.
#### Motivation for adding to Mudlet
Both silent-failure cases are real and user-visible. A game sending a
filename Qt cannot decode, or a file gone from the media cache, would
kill the sound and leak the player's source while reporting nothing; and
`stopMusic()` shortly after `playMusic()` would leak the source every
time on Windows and Linux. A script chaining tracks off
`sysMediaFinished` waited forever in both cases.
They share a root cause with #9566: the deferred release could not tell
what the player was doing when its turn came around. The
`claimSource()`/`continuePlaying()`/`releaseSource()` encapsulation is
the durable part — it turns "remember to bump the counter" from a
convention into something the API does for you.
The test work matters as much as the fixes. The #9566 regression guard
was skipping on every CI job, so it was protecting nothing; both bugs
above were caught only once it actually ran.
#### Other info (issues closed, discussion etc)
Follow-up to #9569 / #9566. No issue number of its own.
Testing notes: the full functional suite passes — 66/66 on Linux, and
the media suite is green on the Ubuntu, Windows and both macOS jobs.
Backends differ in what they can demonstrate, so some media tests skip
by design. Under the environment ctest uses, six of the seven skip on
macOS: the `darwin` backend starts playback synchronously and delivers
`EndOfMedia` before `StoppedState`, so it can stage neither the claim
race nor the #9566 ordering, and it does not decode under
`QT_QPA_PLATFORM=offscreen`. Those paths run on the Ubuntu and Windows
FFmpeg builds, which is where both bugs in this PR were caught. Every
skip prints what the backend could not demonstrate and why, so an inert
guard is visible rather than silent.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-05 00:34:43 -04:00
// Not a backend capability, so not a skip: the harness cannot do its own setup.
QFAIL ( " Could not create a temporary directory for the backend probe. " ) ;
Fix looping sounds playing only once (#9569)
#### Brief overview of PR changes/additions
Looping media (`loops=-1`) plays only once on Qt's FFmpeg multimedia
backend.
That backend ends a track by emitting `StoppedState` **first** and
`EndOfMedia` **second**, and it skips the `EndOfMedia` notification when
the playback engine was destroyed in between:
```cpp
if (currentPlaybackEngine)
stateChanged(QMediaPlayer::StoppedState);
if (currentPlaybackEngine)
mediaStatusChanged(QMediaPlayer::EndOfMedia);
```
Mudlet restarts a loop from its `EndOfMedia` handler, but
`TMedia::handlePlayerPlaybackStateChanged()` called `setSource(QUrl())`
immediately on `StoppedState`. That destroyed the engine, so
`EndOfMedia` never arrived and the loop died after one pass.
This defers the cleanup by one event-loop turn and re-checks the
playback state, so a loop restart can claim the player first while a
genuinely stopped player is still torn down.
#### Motivation for adding to Mudlet
Fixes #9566. Ambient/background music tracks stop after a single pass,
which affects any game using `Client.Media.Play` with `loops: -1`, MSP,
or `playMusicFile()`.
The immediate source clearing was introduced by #9237 as a
resource-cleanup measure. That cleanup is preserved here — it just runs
one event-loop turn later, and only if the player is still stopped.
#### Other info (issues closed, discussion etc)
Fixes #9566. Regression introduced by #9237 (merged 2026-04-29), so 4.21
and 4.22 are affected; 4.20.1 and earlier are not.
**Verified on Windows against the FFmpeg backend.** The bug reproduces
on unfixed code there, and this change resolves it without regressing
the cleanup that #9237 added.
It is worth being explicit that this could not be confirmed on macOS:
that platform ships `libdarwinmediaplugin`, which emits `EndOfMedia`
*before* `StoppedState` — the reverse ordering — so the loop restarts
before any cleanup runs and the bug cannot occur there at all. Reverting
the fix on macOS produced identical behaviour. The fix is therefore
specific to backends with the FFmpeg ordering, which is where the issue
was reported.
A functional test is included
(`test/functional_tests/TMediaLoopTest.cpp`) covering both halves of the
contract: a looping track must survive the stop/restart cycle, and a
one-shot track must still be cleaned up. It probes the backend before
asserting and **skips** unless that backend can both finish a clip and
emit `StoppedState` before `EndOfMedia`. Without that guard the looping
assertion passes on broken code, which is exactly what happened on
macOS. Expected results:
- FFmpeg backend → runs and asserts
- macOS darwin backend → skips (cannot exhibit the bug)
- Anywhere media stalls under `QT_QPA_PLATFORM=offscreen`, which the
functional suite forces → skips
So it may well skip in CI; that is intentional and preferable to a test
that always passes.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-02 13:11:37 -04:00
}
const QString path = qsl ( " %1/probe.wav " ) . arg ( mProbeDir . path ( ) ) ;
QFile file ( path ) ;
if ( ! file . open ( QIODevice : : WriteOnly ) ) {
Fix: Sounds going silent when a file fails to load (#9612)
#### Brief overview of PR changes/additions
Follow-up hardening on top of #9569. Two ways a media player could end
up silent while still holding its source, plus the crash and the test
gaps found chasing them.
**A track that fails to load.** Nothing in `TMedia` listened for
`QMediaPlayer::errorOccurred`. A player that was already stopped when
its source failed reports no playback state change — and that signal is
what ends a playback, releases the source and raises `sysMediaFinished`.
The track fell silent holding a file nothing would ever release. The
error is now acted on.
**A track stopped while it is still loading.** Qt already considers an
unstarted player stopped, so `stop()` draws no state change out of one
mid-load, with the same result. This is not a narrow race: on an
asynchronous backend the Linux and Windows runners hit it every time.
`stopMedia()` now ends such a playback itself instead of waiting for a
report that is never coming.
Around those:
- The deferred source release is a single function shared by the stop,
error and teardown paths. Whether the player's own state gets a say
differs between them, so callers pass a `PlaybackEnd`: a stop needs it,
because a restart may be in flight and a player loading its next source
looks identical to a stopped one; a failure must *not* have it, because
a backend can report `PlayingState` for media it has just failed to
load.
- Re-sourcing a player goes through `claimSource()`, `continuePlaying()`
and `releaseSource()`, so the generation bump that tells a pending
release the track has moved on cannot be forgotten at a call site. A
missed bump is what let an earlier revision of #9569 clear the source of
a track that had just been restarted.
- `stopMedia()` empties the playlist, so an explicit stop cannot leave a
loop armed to restart itself from the `EndOfMedia` handler.
- `setupVideo()` failing now releases the source it claimed, and hides
the video widget only under the same `mediaWidget`/`mediaClose` guards
the deferred release uses — previously it could hide a label belonging
to an earlier clip.
- `src/dlgTriggerEditor.cpp` is here for one guard:
`runScheduledCleanReset()` repopulates itself from a `Host` that a
profile teardown has already destroyed. Unrelated to media, but it
crashed the media tests once they started running.
Behaviour worth knowing about when reviewing:
- `sysMediaFinished` now fires for a failed load and for a stop issued
mid-load, where nothing fired before. It is suppressed when the source
has already been released, where it would only have carried an empty
file name and path.
- `purgeMediaCache()` returns `false` when the directory could not be
fully removed, instead of always returning `true`.
- The closing closed caption is suppressed between the passes of a
looping track, and printed by `stopAllMediaPlayers()`, which releases
synchronously.
- `TMedia` gains three read-only diagnostics used by the tests —
`playersHoldingSource()`, `mediaPlayerCount()` and
`playersInPlayingState()`. A deferred release is otherwise unobservable:
`playingMedia()` has already dropped the player, the caption needs
captions enabled, and the video signal needs a widget.
Tests: new slots for a finite `loops=N` track, an explicit stop, an
unplayable source and a reused player. `probeBackend()` measures what
the backend can demonstrate — whether it starts playback at all, decodes
to `EndOfMedia`, orders `EndOfMedia` against `StoppedState`, starts
synchronously, and reports an undecodable file — and each test skips on
the capabilities it needs, printing what was measured.
`QT_MEDIA_BACKEND` is pinned to whatever `main.cpp` ships per platform,
since `QTEST_MAIN` does not run `main.cpp` and the tests were otherwise
exercising Qt's default backend rather than the one users get.
#### Motivation for adding to Mudlet
Both silent-failure cases are real and user-visible. A game sending a
filename Qt cannot decode, or a file gone from the media cache, would
kill the sound and leak the player's source while reporting nothing; and
`stopMusic()` shortly after `playMusic()` would leak the source every
time on Windows and Linux. A script chaining tracks off
`sysMediaFinished` waited forever in both cases.
They share a root cause with #9566: the deferred release could not tell
what the player was doing when its turn came around. The
`claimSource()`/`continuePlaying()`/`releaseSource()` encapsulation is
the durable part — it turns "remember to bump the counter" from a
convention into something the API does for you.
The test work matters as much as the fixes. The #9566 regression guard
was skipping on every CI job, so it was protecting nothing; both bugs
above were caught only once it actually ran.
#### Other info (issues closed, discussion etc)
Follow-up to #9569 / #9566. No issue number of its own.
Testing notes: the full functional suite passes — 66/66 on Linux, and
the media suite is green on the Ubuntu, Windows and both macOS jobs.
Backends differ in what they can demonstrate, so some media tests skip
by design. Under the environment ctest uses, six of the seven skip on
macOS: the `darwin` backend starts playback synchronously and delivers
`EndOfMedia` before `StoppedState`, so it can stage neither the claim
race nor the #9566 ordering, and it does not decode under
`QT_QPA_PLATFORM=offscreen`. Those paths run on the Ubuntu and Windows
FFmpeg builds, which is where both bugs in this PR were caught. Every
skip prints what the backend could not demonstrate and why, so an inert
guard is visible rather than silent.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-05 00:34:43 -04:00
QFAIL ( " Could not write the backend probe clip. " ) ;
Fix looping sounds playing only once (#9569)
#### Brief overview of PR changes/additions
Looping media (`loops=-1`) plays only once on Qt's FFmpeg multimedia
backend.
That backend ends a track by emitting `StoppedState` **first** and
`EndOfMedia` **second**, and it skips the `EndOfMedia` notification when
the playback engine was destroyed in between:
```cpp
if (currentPlaybackEngine)
stateChanged(QMediaPlayer::StoppedState);
if (currentPlaybackEngine)
mediaStatusChanged(QMediaPlayer::EndOfMedia);
```
Mudlet restarts a loop from its `EndOfMedia` handler, but
`TMedia::handlePlayerPlaybackStateChanged()` called `setSource(QUrl())`
immediately on `StoppedState`. That destroyed the engine, so
`EndOfMedia` never arrived and the loop died after one pass.
This defers the cleanup by one event-loop turn and re-checks the
playback state, so a loop restart can claim the player first while a
genuinely stopped player is still torn down.
#### Motivation for adding to Mudlet
Fixes #9566. Ambient/background music tracks stop after a single pass,
which affects any game using `Client.Media.Play` with `loops: -1`, MSP,
or `playMusicFile()`.
The immediate source clearing was introduced by #9237 as a
resource-cleanup measure. That cleanup is preserved here — it just runs
one event-loop turn later, and only if the player is still stopped.
#### Other info (issues closed, discussion etc)
Fixes #9566. Regression introduced by #9237 (merged 2026-04-29), so 4.21
and 4.22 are affected; 4.20.1 and earlier are not.
**Verified on Windows against the FFmpeg backend.** The bug reproduces
on unfixed code there, and this change resolves it without regressing
the cleanup that #9237 added.
It is worth being explicit that this could not be confirmed on macOS:
that platform ships `libdarwinmediaplugin`, which emits `EndOfMedia`
*before* `StoppedState` — the reverse ordering — so the loop restarts
before any cleanup runs and the bug cannot occur there at all. Reverting
the fix on macOS produced identical behaviour. The fix is therefore
specific to backends with the FFmpeg ordering, which is where the issue
was reported.
A functional test is included
(`test/functional_tests/TMediaLoopTest.cpp`) covering both halves of the
contract: a looping track must survive the stop/restart cycle, and a
one-shot track must still be cleaned up. It probes the backend before
asserting and **skips** unless that backend can both finish a clip and
emit `StoppedState` before `EndOfMedia`. Without that guard the looping
assertion passes on broken code, which is exactly what happened on
macOS. Expected results:
- FFmpeg backend → runs and asserts
- macOS darwin backend → skips (cannot exhibit the bug)
- Anywhere media stalls under `QT_QPA_PLATFORM=offscreen`, which the
functional suite forces → skips
So it may well skip in CI; that is intentional and preferable to a test
that always passes.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-02 13:11:37 -04:00
}
file . write ( wavBytes ( ) ) ;
file . close ( ) ;
QMediaPlayer probe ;
auto * output = new QAudioOutput ( & probe ) ;
output - > setMuted ( true ) ;
probe . setAudioOutput ( output ) ;
bool sawEndOfMedia = false ;
bool stoppedCameFirst = false ;
Fix: Sounds going silent when a file fails to load (#9612)
#### Brief overview of PR changes/additions
Follow-up hardening on top of #9569. Two ways a media player could end
up silent while still holding its source, plus the crash and the test
gaps found chasing them.
**A track that fails to load.** Nothing in `TMedia` listened for
`QMediaPlayer::errorOccurred`. A player that was already stopped when
its source failed reports no playback state change — and that signal is
what ends a playback, releases the source and raises `sysMediaFinished`.
The track fell silent holding a file nothing would ever release. The
error is now acted on.
**A track stopped while it is still loading.** Qt already considers an
unstarted player stopped, so `stop()` draws no state change out of one
mid-load, with the same result. This is not a narrow race: on an
asynchronous backend the Linux and Windows runners hit it every time.
`stopMedia()` now ends such a playback itself instead of waiting for a
report that is never coming.
Around those:
- The deferred source release is a single function shared by the stop,
error and teardown paths. Whether the player's own state gets a say
differs between them, so callers pass a `PlaybackEnd`: a stop needs it,
because a restart may be in flight and a player loading its next source
looks identical to a stopped one; a failure must *not* have it, because
a backend can report `PlayingState` for media it has just failed to
load.
- Re-sourcing a player goes through `claimSource()`, `continuePlaying()`
and `releaseSource()`, so the generation bump that tells a pending
release the track has moved on cannot be forgotten at a call site. A
missed bump is what let an earlier revision of #9569 clear the source of
a track that had just been restarted.
- `stopMedia()` empties the playlist, so an explicit stop cannot leave a
loop armed to restart itself from the `EndOfMedia` handler.
- `setupVideo()` failing now releases the source it claimed, and hides
the video widget only under the same `mediaWidget`/`mediaClose` guards
the deferred release uses — previously it could hide a label belonging
to an earlier clip.
- `src/dlgTriggerEditor.cpp` is here for one guard:
`runScheduledCleanReset()` repopulates itself from a `Host` that a
profile teardown has already destroyed. Unrelated to media, but it
crashed the media tests once they started running.
Behaviour worth knowing about when reviewing:
- `sysMediaFinished` now fires for a failed load and for a stop issued
mid-load, where nothing fired before. It is suppressed when the source
has already been released, where it would only have carried an empty
file name and path.
- `purgeMediaCache()` returns `false` when the directory could not be
fully removed, instead of always returning `true`.
- The closing closed caption is suppressed between the passes of a
looping track, and printed by `stopAllMediaPlayers()`, which releases
synchronously.
- `TMedia` gains three read-only diagnostics used by the tests —
`playersHoldingSource()`, `mediaPlayerCount()` and
`playersInPlayingState()`. A deferred release is otherwise unobservable:
`playingMedia()` has already dropped the player, the caption needs
captions enabled, and the video signal needs a widget.
Tests: new slots for a finite `loops=N` track, an explicit stop, an
unplayable source and a reused player. `probeBackend()` measures what
the backend can demonstrate — whether it starts playback at all, decodes
to `EndOfMedia`, orders `EndOfMedia` against `StoppedState`, starts
synchronously, and reports an undecodable file — and each test skips on
the capabilities it needs, printing what was measured.
`QT_MEDIA_BACKEND` is pinned to whatever `main.cpp` ships per platform,
since `QTEST_MAIN` does not run `main.cpp` and the tests were otherwise
exercising Qt's default backend rather than the one users get.
#### Motivation for adding to Mudlet
Both silent-failure cases are real and user-visible. A game sending a
filename Qt cannot decode, or a file gone from the media cache, would
kill the sound and leak the player's source while reporting nothing; and
`stopMusic()` shortly after `playMusic()` would leak the source every
time on Windows and Linux. A script chaining tracks off
`sysMediaFinished` waited forever in both cases.
They share a root cause with #9566: the deferred release could not tell
what the player was doing when its turn came around. The
`claimSource()`/`continuePlaying()`/`releaseSource()` encapsulation is
the durable part — it turns "remember to bump the counter" from a
convention into something the API does for you.
The test work matters as much as the fixes. The #9566 regression guard
was skipping on every CI job, so it was protecting nothing; both bugs
above were caught only once it actually ran.
#### Other info (issues closed, discussion etc)
Follow-up to #9569 / #9566. No issue number of its own.
Testing notes: the full functional suite passes — 66/66 on Linux, and
the media suite is green on the Ubuntu, Windows and both macOS jobs.
Backends differ in what they can demonstrate, so some media tests skip
by design. Under the environment ctest uses, six of the seven skip on
macOS: the `darwin` backend starts playback synchronously and delivers
`EndOfMedia` before `StoppedState`, so it can stage neither the claim
race nor the #9566 ordering, and it does not decode under
`QT_QPA_PLATFORM=offscreen`. Those paths run on the Ubuntu and Windows
FFmpeg builds, which is where both bugs in this PR were caught. Every
skip prints what the backend could not demonstrate and why, so an inert
guard is visible rather than silent.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-05 00:34:43 -04:00
bool sawPlaying = false ;
Fix looping sounds playing only once (#9569)
#### Brief overview of PR changes/additions
Looping media (`loops=-1`) plays only once on Qt's FFmpeg multimedia
backend.
That backend ends a track by emitting `StoppedState` **first** and
`EndOfMedia` **second**, and it skips the `EndOfMedia` notification when
the playback engine was destroyed in between:
```cpp
if (currentPlaybackEngine)
stateChanged(QMediaPlayer::StoppedState);
if (currentPlaybackEngine)
mediaStatusChanged(QMediaPlayer::EndOfMedia);
```
Mudlet restarts a loop from its `EndOfMedia` handler, but
`TMedia::handlePlayerPlaybackStateChanged()` called `setSource(QUrl())`
immediately on `StoppedState`. That destroyed the engine, so
`EndOfMedia` never arrived and the loop died after one pass.
This defers the cleanup by one event-loop turn and re-checks the
playback state, so a loop restart can claim the player first while a
genuinely stopped player is still torn down.
#### Motivation for adding to Mudlet
Fixes #9566. Ambient/background music tracks stop after a single pass,
which affects any game using `Client.Media.Play` with `loops: -1`, MSP,
or `playMusicFile()`.
The immediate source clearing was introduced by #9237 as a
resource-cleanup measure. That cleanup is preserved here — it just runs
one event-loop turn later, and only if the player is still stopped.
#### Other info (issues closed, discussion etc)
Fixes #9566. Regression introduced by #9237 (merged 2026-04-29), so 4.21
and 4.22 are affected; 4.20.1 and earlier are not.
**Verified on Windows against the FFmpeg backend.** The bug reproduces
on unfixed code there, and this change resolves it without regressing
the cleanup that #9237 added.
It is worth being explicit that this could not be confirmed on macOS:
that platform ships `libdarwinmediaplugin`, which emits `EndOfMedia`
*before* `StoppedState` — the reverse ordering — so the loop restarts
before any cleanup runs and the bug cannot occur there at all. Reverting
the fix on macOS produced identical behaviour. The fix is therefore
specific to backends with the FFmpeg ordering, which is where the issue
was reported.
A functional test is included
(`test/functional_tests/TMediaLoopTest.cpp`) covering both halves of the
contract: a looping track must survive the stop/restart cycle, and a
one-shot track must still be cleaned up. It probes the backend before
asserting and **skips** unless that backend can both finish a clip and
emit `StoppedState` before `EndOfMedia`. Without that guard the looping
assertion passes on broken code, which is exactly what happened on
macOS. Expected results:
- FFmpeg backend → runs and asserts
- macOS darwin backend → skips (cannot exhibit the bug)
- Anywhere media stalls under `QT_QPA_PLATFORM=offscreen`, which the
functional suite forces → skips
So it may well skip in CI; that is intentional and preferable to a test
that always passes.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-02 13:11:37 -04:00
connect ( & probe , & QMediaPlayer : : mediaStatusChanged , this , [ & ] ( QMediaPlayer : : MediaStatus status ) {
if ( status = = QMediaPlayer : : EndOfMedia ) {
sawEndOfMedia = true ;
}
} ) ;
connect ( & probe , & QMediaPlayer : : playbackStateChanged , this , [ & ] ( QMediaPlayer : : PlaybackState state ) {
Fix: Sounds going silent when a file fails to load (#9612)
#### Brief overview of PR changes/additions
Follow-up hardening on top of #9569. Two ways a media player could end
up silent while still holding its source, plus the crash and the test
gaps found chasing them.
**A track that fails to load.** Nothing in `TMedia` listened for
`QMediaPlayer::errorOccurred`. A player that was already stopped when
its source failed reports no playback state change — and that signal is
what ends a playback, releases the source and raises `sysMediaFinished`.
The track fell silent holding a file nothing would ever release. The
error is now acted on.
**A track stopped while it is still loading.** Qt already considers an
unstarted player stopped, so `stop()` draws no state change out of one
mid-load, with the same result. This is not a narrow race: on an
asynchronous backend the Linux and Windows runners hit it every time.
`stopMedia()` now ends such a playback itself instead of waiting for a
report that is never coming.
Around those:
- The deferred source release is a single function shared by the stop,
error and teardown paths. Whether the player's own state gets a say
differs between them, so callers pass a `PlaybackEnd`: a stop needs it,
because a restart may be in flight and a player loading its next source
looks identical to a stopped one; a failure must *not* have it, because
a backend can report `PlayingState` for media it has just failed to
load.
- Re-sourcing a player goes through `claimSource()`, `continuePlaying()`
and `releaseSource()`, so the generation bump that tells a pending
release the track has moved on cannot be forgotten at a call site. A
missed bump is what let an earlier revision of #9569 clear the source of
a track that had just been restarted.
- `stopMedia()` empties the playlist, so an explicit stop cannot leave a
loop armed to restart itself from the `EndOfMedia` handler.
- `setupVideo()` failing now releases the source it claimed, and hides
the video widget only under the same `mediaWidget`/`mediaClose` guards
the deferred release uses — previously it could hide a label belonging
to an earlier clip.
- `src/dlgTriggerEditor.cpp` is here for one guard:
`runScheduledCleanReset()` repopulates itself from a `Host` that a
profile teardown has already destroyed. Unrelated to media, but it
crashed the media tests once they started running.
Behaviour worth knowing about when reviewing:
- `sysMediaFinished` now fires for a failed load and for a stop issued
mid-load, where nothing fired before. It is suppressed when the source
has already been released, where it would only have carried an empty
file name and path.
- `purgeMediaCache()` returns `false` when the directory could not be
fully removed, instead of always returning `true`.
- The closing closed caption is suppressed between the passes of a
looping track, and printed by `stopAllMediaPlayers()`, which releases
synchronously.
- `TMedia` gains three read-only diagnostics used by the tests —
`playersHoldingSource()`, `mediaPlayerCount()` and
`playersInPlayingState()`. A deferred release is otherwise unobservable:
`playingMedia()` has already dropped the player, the caption needs
captions enabled, and the video signal needs a widget.
Tests: new slots for a finite `loops=N` track, an explicit stop, an
unplayable source and a reused player. `probeBackend()` measures what
the backend can demonstrate — whether it starts playback at all, decodes
to `EndOfMedia`, orders `EndOfMedia` against `StoppedState`, starts
synchronously, and reports an undecodable file — and each test skips on
the capabilities it needs, printing what was measured.
`QT_MEDIA_BACKEND` is pinned to whatever `main.cpp` ships per platform,
since `QTEST_MAIN` does not run `main.cpp` and the tests were otherwise
exercising Qt's default backend rather than the one users get.
#### Motivation for adding to Mudlet
Both silent-failure cases are real and user-visible. A game sending a
filename Qt cannot decode, or a file gone from the media cache, would
kill the sound and leak the player's source while reporting nothing; and
`stopMusic()` shortly after `playMusic()` would leak the source every
time on Windows and Linux. A script chaining tracks off
`sysMediaFinished` waited forever in both cases.
They share a root cause with #9566: the deferred release could not tell
what the player was doing when its turn came around. The
`claimSource()`/`continuePlaying()`/`releaseSource()` encapsulation is
the durable part — it turns "remember to bump the counter" from a
convention into something the API does for you.
The test work matters as much as the fixes. The #9566 regression guard
was skipping on every CI job, so it was protecting nothing; both bugs
above were caught only once it actually ran.
#### Other info (issues closed, discussion etc)
Follow-up to #9569 / #9566. No issue number of its own.
Testing notes: the full functional suite passes — 66/66 on Linux, and
the media suite is green on the Ubuntu, Windows and both macOS jobs.
Backends differ in what they can demonstrate, so some media tests skip
by design. Under the environment ctest uses, six of the seven skip on
macOS: the `darwin` backend starts playback synchronously and delivers
`EndOfMedia` before `StoppedState`, so it can stage neither the claim
race nor the #9566 ordering, and it does not decode under
`QT_QPA_PLATFORM=offscreen`. Those paths run on the Ubuntu and Windows
FFmpeg builds, which is where both bugs in this PR were caught. Every
skip prints what the backend could not demonstrate and why, so an inert
guard is visible rather than silent.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-05 00:34:43 -04:00
if ( state = = QMediaPlayer : : PlayingState ) {
sawPlaying = true ;
}
Fix looping sounds playing only once (#9569)
#### Brief overview of PR changes/additions
Looping media (`loops=-1`) plays only once on Qt's FFmpeg multimedia
backend.
That backend ends a track by emitting `StoppedState` **first** and
`EndOfMedia` **second**, and it skips the `EndOfMedia` notification when
the playback engine was destroyed in between:
```cpp
if (currentPlaybackEngine)
stateChanged(QMediaPlayer::StoppedState);
if (currentPlaybackEngine)
mediaStatusChanged(QMediaPlayer::EndOfMedia);
```
Mudlet restarts a loop from its `EndOfMedia` handler, but
`TMedia::handlePlayerPlaybackStateChanged()` called `setSource(QUrl())`
immediately on `StoppedState`. That destroyed the engine, so
`EndOfMedia` never arrived and the loop died after one pass.
This defers the cleanup by one event-loop turn and re-checks the
playback state, so a loop restart can claim the player first while a
genuinely stopped player is still torn down.
#### Motivation for adding to Mudlet
Fixes #9566. Ambient/background music tracks stop after a single pass,
which affects any game using `Client.Media.Play` with `loops: -1`, MSP,
or `playMusicFile()`.
The immediate source clearing was introduced by #9237 as a
resource-cleanup measure. That cleanup is preserved here — it just runs
one event-loop turn later, and only if the player is still stopped.
#### Other info (issues closed, discussion etc)
Fixes #9566. Regression introduced by #9237 (merged 2026-04-29), so 4.21
and 4.22 are affected; 4.20.1 and earlier are not.
**Verified on Windows against the FFmpeg backend.** The bug reproduces
on unfixed code there, and this change resolves it without regressing
the cleanup that #9237 added.
It is worth being explicit that this could not be confirmed on macOS:
that platform ships `libdarwinmediaplugin`, which emits `EndOfMedia`
*before* `StoppedState` — the reverse ordering — so the loop restarts
before any cleanup runs and the bug cannot occur there at all. Reverting
the fix on macOS produced identical behaviour. The fix is therefore
specific to backends with the FFmpeg ordering, which is where the issue
was reported.
A functional test is included
(`test/functional_tests/TMediaLoopTest.cpp`) covering both halves of the
contract: a looping track must survive the stop/restart cycle, and a
one-shot track must still be cleaned up. It probes the backend before
asserting and **skips** unless that backend can both finish a clip and
emit `StoppedState` before `EndOfMedia`. Without that guard the looping
assertion passes on broken code, which is exactly what happened on
macOS. Expected results:
- FFmpeg backend → runs and asserts
- macOS darwin backend → skips (cannot exhibit the bug)
- Anywhere media stalls under `QT_QPA_PLATFORM=offscreen`, which the
functional suite forces → skips
So it may well skip in CI; that is intentional and preferable to a test
that always passes.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-02 13:11:37 -04:00
if ( state = = QMediaPlayer : : StoppedState & & ! sawEndOfMedia ) {
stoppedCameFirst = true ;
}
} ) ;
probe . setSource ( QUrl : : fromLocalFile ( path ) ) ;
probe . play ( ) ;
// Whether a player that has just been handed a source still reads as stopped is
// the whole reason the deferred cleanup needs to check who owns the player.
const bool startsSynchronously = probe . playbackState ( ) = = QMediaPlayer : : PlayingState ;
const bool finished = QTest : : qWaitFor (
[ & ] ( ) {
return sawEndOfMedia ;
} ,
QDeadlineTimer ( 10 s ) ) ;
probe . stop ( ) ;
Fix: Sounds going silent when a file fails to load (#9612)
#### Brief overview of PR changes/additions
Follow-up hardening on top of #9569. Two ways a media player could end
up silent while still holding its source, plus the crash and the test
gaps found chasing them.
**A track that fails to load.** Nothing in `TMedia` listened for
`QMediaPlayer::errorOccurred`. A player that was already stopped when
its source failed reports no playback state change — and that signal is
what ends a playback, releases the source and raises `sysMediaFinished`.
The track fell silent holding a file nothing would ever release. The
error is now acted on.
**A track stopped while it is still loading.** Qt already considers an
unstarted player stopped, so `stop()` draws no state change out of one
mid-load, with the same result. This is not a narrow race: on an
asynchronous backend the Linux and Windows runners hit it every time.
`stopMedia()` now ends such a playback itself instead of waiting for a
report that is never coming.
Around those:
- The deferred source release is a single function shared by the stop,
error and teardown paths. Whether the player's own state gets a say
differs between them, so callers pass a `PlaybackEnd`: a stop needs it,
because a restart may be in flight and a player loading its next source
looks identical to a stopped one; a failure must *not* have it, because
a backend can report `PlayingState` for media it has just failed to
load.
- Re-sourcing a player goes through `claimSource()`, `continuePlaying()`
and `releaseSource()`, so the generation bump that tells a pending
release the track has moved on cannot be forgotten at a call site. A
missed bump is what let an earlier revision of #9569 clear the source of
a track that had just been restarted.
- `stopMedia()` empties the playlist, so an explicit stop cannot leave a
loop armed to restart itself from the `EndOfMedia` handler.
- `setupVideo()` failing now releases the source it claimed, and hides
the video widget only under the same `mediaWidget`/`mediaClose` guards
the deferred release uses — previously it could hide a label belonging
to an earlier clip.
- `src/dlgTriggerEditor.cpp` is here for one guard:
`runScheduledCleanReset()` repopulates itself from a `Host` that a
profile teardown has already destroyed. Unrelated to media, but it
crashed the media tests once they started running.
Behaviour worth knowing about when reviewing:
- `sysMediaFinished` now fires for a failed load and for a stop issued
mid-load, where nothing fired before. It is suppressed when the source
has already been released, where it would only have carried an empty
file name and path.
- `purgeMediaCache()` returns `false` when the directory could not be
fully removed, instead of always returning `true`.
- The closing closed caption is suppressed between the passes of a
looping track, and printed by `stopAllMediaPlayers()`, which releases
synchronously.
- `TMedia` gains three read-only diagnostics used by the tests —
`playersHoldingSource()`, `mediaPlayerCount()` and
`playersInPlayingState()`. A deferred release is otherwise unobservable:
`playingMedia()` has already dropped the player, the caption needs
captions enabled, and the video signal needs a widget.
Tests: new slots for a finite `loops=N` track, an explicit stop, an
unplayable source and a reused player. `probeBackend()` measures what
the backend can demonstrate — whether it starts playback at all, decodes
to `EndOfMedia`, orders `EndOfMedia` against `StoppedState`, starts
synchronously, and reports an undecodable file — and each test skips on
the capabilities it needs, printing what was measured.
`QT_MEDIA_BACKEND` is pinned to whatever `main.cpp` ships per platform,
since `QTEST_MAIN` does not run `main.cpp` and the tests were otherwise
exercising Qt's default backend rather than the one users get.
#### Motivation for adding to Mudlet
Both silent-failure cases are real and user-visible. A game sending a
filename Qt cannot decode, or a file gone from the media cache, would
kill the sound and leak the player's source while reporting nothing; and
`stopMusic()` shortly after `playMusic()` would leak the source every
time on Windows and Linux. A script chaining tracks off
`sysMediaFinished` waited forever in both cases.
They share a root cause with #9566: the deferred release could not tell
what the player was doing when its turn came around. The
`claimSource()`/`continuePlaying()`/`releaseSource()` encapsulation is
the durable part — it turns "remember to bump the counter" from a
convention into something the API does for you.
The test work matters as much as the fixes. The #9566 regression guard
was skipping on every CI job, so it was protecting nothing; both bugs
above were caught only once it actually ran.
#### Other info (issues closed, discussion etc)
Follow-up to #9569 / #9566. No issue number of its own.
Testing notes: the full functional suite passes — 66/66 on Linux, and
the media suite is green on the Ubuntu, Windows and both macOS jobs.
Backends differ in what they can demonstrate, so some media tests skip
by design. Under the environment ctest uses, six of the seven skip on
macOS: the `darwin` backend starts playback synchronously and delivers
`EndOfMedia` before `StoppedState`, so it can stage neither the claim
race nor the #9566 ordering, and it does not decode under
`QT_QPA_PLATFORM=offscreen`. Those paths run on the Ubuntu and Windows
FFmpeg builds, which is where both bugs in this PR were caught. Every
skip prints what the backend could not demonstrate and why, so an inert
guard is visible rather than silent.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-05 00:34:43 -04:00
// Both recorded before the decode verdict below, because the tests that need them do
// not need the backend to finish a clip - an early return here would leave them
// believing this backend starts playback and loads asynchronously when it does neither.
if ( startsSynchronously ) {
mSynchronousStartReason = qsl ( " This Qt Multimedia backend reaches PlayingState synchronously, so a player that has just been claimed by another track never reads as stopped and cannot "
" have its source cleared out from under it. Needs a backend that loads asynchronously, such as Qt's FFmpeg one. " ) ;
}
if ( ! sawPlaying & & ! startsSynchronously ) {
// Without this every test that only stops a track - the ones that need nothing else
// of the backend - would fail its opening "never started playing" assertion rather
// than skip, which is a red build on any runner without a usable backend.
mCannotStartReason = qsl ( " This Qt Multimedia backend never reached PlayingState within 10s, so no playback can be started to act on. Backend: \" %1 \" , final media status: %2, error: "
" \" %3 \" . " )
. arg ( QString : : fromLocal8Bit ( qgetenv ( " QT_MEDIA_BACKEND " ) ) , QString : : number ( static_cast < int > ( probe . mediaStatus ( ) ) ) , probe . errorString ( ) ) ;
}
Fix looping sounds playing only once (#9569)
#### Brief overview of PR changes/additions
Looping media (`loops=-1`) plays only once on Qt's FFmpeg multimedia
backend.
That backend ends a track by emitting `StoppedState` **first** and
`EndOfMedia` **second**, and it skips the `EndOfMedia` notification when
the playback engine was destroyed in between:
```cpp
if (currentPlaybackEngine)
stateChanged(QMediaPlayer::StoppedState);
if (currentPlaybackEngine)
mediaStatusChanged(QMediaPlayer::EndOfMedia);
```
Mudlet restarts a loop from its `EndOfMedia` handler, but
`TMedia::handlePlayerPlaybackStateChanged()` called `setSource(QUrl())`
immediately on `StoppedState`. That destroyed the engine, so
`EndOfMedia` never arrived and the loop died after one pass.
This defers the cleanup by one event-loop turn and re-checks the
playback state, so a loop restart can claim the player first while a
genuinely stopped player is still torn down.
#### Motivation for adding to Mudlet
Fixes #9566. Ambient/background music tracks stop after a single pass,
which affects any game using `Client.Media.Play` with `loops: -1`, MSP,
or `playMusicFile()`.
The immediate source clearing was introduced by #9237 as a
resource-cleanup measure. That cleanup is preserved here — it just runs
one event-loop turn later, and only if the player is still stopped.
#### Other info (issues closed, discussion etc)
Fixes #9566. Regression introduced by #9237 (merged 2026-04-29), so 4.21
and 4.22 are affected; 4.20.1 and earlier are not.
**Verified on Windows against the FFmpeg backend.** The bug reproduces
on unfixed code there, and this change resolves it without regressing
the cleanup that #9237 added.
It is worth being explicit that this could not be confirmed on macOS:
that platform ships `libdarwinmediaplugin`, which emits `EndOfMedia`
*before* `StoppedState` — the reverse ordering — so the loop restarts
before any cleanup runs and the bug cannot occur there at all. Reverting
the fix on macOS produced identical behaviour. The fix is therefore
specific to backends with the FFmpeg ordering, which is where the issue
was reported.
A functional test is included
(`test/functional_tests/TMediaLoopTest.cpp`) covering both halves of the
contract: a looping track must survive the stop/restart cycle, and a
one-shot track must still be cleaned up. It probes the backend before
asserting and **skips** unless that backend can both finish a clip and
emit `StoppedState` before `EndOfMedia`. Without that guard the looping
assertion passes on broken code, which is exactly what happened on
macOS. Expected results:
- FFmpeg backend → runs and asserts
- macOS darwin backend → skips (cannot exhibit the bug)
- Anywhere media stalls under `QT_QPA_PLATFORM=offscreen`, which the
functional suite forces → skips
So it may well skip in CI; that is intentional and preferable to a test
that always passes.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-02 13:11:37 -04:00
if ( ! finished ) {
Fix: Sounds going silent when a file fails to load (#9612)
#### Brief overview of PR changes/additions
Follow-up hardening on top of #9569. Two ways a media player could end
up silent while still holding its source, plus the crash and the test
gaps found chasing them.
**A track that fails to load.** Nothing in `TMedia` listened for
`QMediaPlayer::errorOccurred`. A player that was already stopped when
its source failed reports no playback state change — and that signal is
what ends a playback, releases the source and raises `sysMediaFinished`.
The track fell silent holding a file nothing would ever release. The
error is now acted on.
**A track stopped while it is still loading.** Qt already considers an
unstarted player stopped, so `stop()` draws no state change out of one
mid-load, with the same result. This is not a narrow race: on an
asynchronous backend the Linux and Windows runners hit it every time.
`stopMedia()` now ends such a playback itself instead of waiting for a
report that is never coming.
Around those:
- The deferred source release is a single function shared by the stop,
error and teardown paths. Whether the player's own state gets a say
differs between them, so callers pass a `PlaybackEnd`: a stop needs it,
because a restart may be in flight and a player loading its next source
looks identical to a stopped one; a failure must *not* have it, because
a backend can report `PlayingState` for media it has just failed to
load.
- Re-sourcing a player goes through `claimSource()`, `continuePlaying()`
and `releaseSource()`, so the generation bump that tells a pending
release the track has moved on cannot be forgotten at a call site. A
missed bump is what let an earlier revision of #9569 clear the source of
a track that had just been restarted.
- `stopMedia()` empties the playlist, so an explicit stop cannot leave a
loop armed to restart itself from the `EndOfMedia` handler.
- `setupVideo()` failing now releases the source it claimed, and hides
the video widget only under the same `mediaWidget`/`mediaClose` guards
the deferred release uses — previously it could hide a label belonging
to an earlier clip.
- `src/dlgTriggerEditor.cpp` is here for one guard:
`runScheduledCleanReset()` repopulates itself from a `Host` that a
profile teardown has already destroyed. Unrelated to media, but it
crashed the media tests once they started running.
Behaviour worth knowing about when reviewing:
- `sysMediaFinished` now fires for a failed load and for a stop issued
mid-load, where nothing fired before. It is suppressed when the source
has already been released, where it would only have carried an empty
file name and path.
- `purgeMediaCache()` returns `false` when the directory could not be
fully removed, instead of always returning `true`.
- The closing closed caption is suppressed between the passes of a
looping track, and printed by `stopAllMediaPlayers()`, which releases
synchronously.
- `TMedia` gains three read-only diagnostics used by the tests —
`playersHoldingSource()`, `mediaPlayerCount()` and
`playersInPlayingState()`. A deferred release is otherwise unobservable:
`playingMedia()` has already dropped the player, the caption needs
captions enabled, and the video signal needs a widget.
Tests: new slots for a finite `loops=N` track, an explicit stop, an
unplayable source and a reused player. `probeBackend()` measures what
the backend can demonstrate — whether it starts playback at all, decodes
to `EndOfMedia`, orders `EndOfMedia` against `StoppedState`, starts
synchronously, and reports an undecodable file — and each test skips on
the capabilities it needs, printing what was measured.
`QT_MEDIA_BACKEND` is pinned to whatever `main.cpp` ships per platform,
since `QTEST_MAIN` does not run `main.cpp` and the tests were otherwise
exercising Qt's default backend rather than the one users get.
#### Motivation for adding to Mudlet
Both silent-failure cases are real and user-visible. A game sending a
filename Qt cannot decode, or a file gone from the media cache, would
kill the sound and leak the player's source while reporting nothing; and
`stopMusic()` shortly after `playMusic()` would leak the source every
time on Windows and Linux. A script chaining tracks off
`sysMediaFinished` waited forever in both cases.
They share a root cause with #9566: the deferred release could not tell
what the player was doing when its turn came around. The
`claimSource()`/`continuePlaying()`/`releaseSource()` encapsulation is
the durable part — it turns "remember to bump the counter" from a
convention into something the API does for you.
The test work matters as much as the fixes. The #9566 regression guard
was skipping on every CI job, so it was protecting nothing; both bugs
above were caught only once it actually ran.
#### Other info (issues closed, discussion etc)
Follow-up to #9569 / #9566. No issue number of its own.
Testing notes: the full functional suite passes — 66/66 on Linux, and
the media suite is green on the Ubuntu, Windows and both macOS jobs.
Backends differ in what they can demonstrate, so some media tests skip
by design. Under the environment ctest uses, six of the seven skip on
macOS: the `darwin` backend starts playback synchronously and delivers
`EndOfMedia` before `StoppedState`, so it can stage neither the claim
race nor the #9566 ordering, and it does not decode under
`QT_QPA_PLATFORM=offscreen`. Those paths run on the Ubuntu and Windows
FFmpeg builds, which is where both bugs in this PR were caught. Every
skip prints what the backend could not demonstrate and why, so an inert
guard is visible rather than silent.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-05 00:34:43 -04:00
// Report what was measured rather than a cause that was not diagnosed - no audio
// device, a missing codec and a stalled decoder all land here.
mCannotPlayReason = qsl ( " This Qt Multimedia backend did not reach EndOfMedia within 10s, so anything that waits for a clip to finish cannot be observed. Backend: \" %1 \" , reached "
" PlayingState: %2, final media status: %3, error: \" %4 \" . " )
. arg ( QString : : fromLocal8Bit ( qgetenv ( " QT_MEDIA_BACKEND " ) ) ,
startsSynchronously ? qsl ( " yes " ) : qsl ( " no " ) ,
QString : : number ( static_cast < int > ( probe . mediaStatus ( ) ) ) ,
probe . errorString ( ) ) ;
Fix looping sounds playing only once (#9569)
#### Brief overview of PR changes/additions
Looping media (`loops=-1`) plays only once on Qt's FFmpeg multimedia
backend.
That backend ends a track by emitting `StoppedState` **first** and
`EndOfMedia` **second**, and it skips the `EndOfMedia` notification when
the playback engine was destroyed in between:
```cpp
if (currentPlaybackEngine)
stateChanged(QMediaPlayer::StoppedState);
if (currentPlaybackEngine)
mediaStatusChanged(QMediaPlayer::EndOfMedia);
```
Mudlet restarts a loop from its `EndOfMedia` handler, but
`TMedia::handlePlayerPlaybackStateChanged()` called `setSource(QUrl())`
immediately on `StoppedState`. That destroyed the engine, so
`EndOfMedia` never arrived and the loop died after one pass.
This defers the cleanup by one event-loop turn and re-checks the
playback state, so a loop restart can claim the player first while a
genuinely stopped player is still torn down.
#### Motivation for adding to Mudlet
Fixes #9566. Ambient/background music tracks stop after a single pass,
which affects any game using `Client.Media.Play` with `loops: -1`, MSP,
or `playMusicFile()`.
The immediate source clearing was introduced by #9237 as a
resource-cleanup measure. That cleanup is preserved here — it just runs
one event-loop turn later, and only if the player is still stopped.
#### Other info (issues closed, discussion etc)
Fixes #9566. Regression introduced by #9237 (merged 2026-04-29), so 4.21
and 4.22 are affected; 4.20.1 and earlier are not.
**Verified on Windows against the FFmpeg backend.** The bug reproduces
on unfixed code there, and this change resolves it without regressing
the cleanup that #9237 added.
It is worth being explicit that this could not be confirmed on macOS:
that platform ships `libdarwinmediaplugin`, which emits `EndOfMedia`
*before* `StoppedState` — the reverse ordering — so the loop restarts
before any cleanup runs and the bug cannot occur there at all. Reverting
the fix on macOS produced identical behaviour. The fix is therefore
specific to backends with the FFmpeg ordering, which is where the issue
was reported.
A functional test is included
(`test/functional_tests/TMediaLoopTest.cpp`) covering both halves of the
contract: a looping track must survive the stop/restart cycle, and a
one-shot track must still be cleaned up. It probes the backend before
asserting and **skips** unless that backend can both finish a clip and
emit `StoppedState` before `EndOfMedia`. Without that guard the looping
assertion passes on broken code, which is exactly what happened on
macOS. Expected results:
- FFmpeg backend → runs and asserts
- macOS darwin backend → skips (cannot exhibit the bug)
- Anywhere media stalls under `QT_QPA_PLATFORM=offscreen`, which the
functional suite forces → skips
So it may well skip in CI; that is intentional and preferable to a test
that always passes.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-02 13:11:37 -04:00
return ;
}
Fix: Sounds going silent when a file fails to load (#9612)
#### Brief overview of PR changes/additions
Follow-up hardening on top of #9569. Two ways a media player could end
up silent while still holding its source, plus the crash and the test
gaps found chasing them.
**A track that fails to load.** Nothing in `TMedia` listened for
`QMediaPlayer::errorOccurred`. A player that was already stopped when
its source failed reports no playback state change — and that signal is
what ends a playback, releases the source and raises `sysMediaFinished`.
The track fell silent holding a file nothing would ever release. The
error is now acted on.
**A track stopped while it is still loading.** Qt already considers an
unstarted player stopped, so `stop()` draws no state change out of one
mid-load, with the same result. This is not a narrow race: on an
asynchronous backend the Linux and Windows runners hit it every time.
`stopMedia()` now ends such a playback itself instead of waiting for a
report that is never coming.
Around those:
- The deferred source release is a single function shared by the stop,
error and teardown paths. Whether the player's own state gets a say
differs between them, so callers pass a `PlaybackEnd`: a stop needs it,
because a restart may be in flight and a player loading its next source
looks identical to a stopped one; a failure must *not* have it, because
a backend can report `PlayingState` for media it has just failed to
load.
- Re-sourcing a player goes through `claimSource()`, `continuePlaying()`
and `releaseSource()`, so the generation bump that tells a pending
release the track has moved on cannot be forgotten at a call site. A
missed bump is what let an earlier revision of #9569 clear the source of
a track that had just been restarted.
- `stopMedia()` empties the playlist, so an explicit stop cannot leave a
loop armed to restart itself from the `EndOfMedia` handler.
- `setupVideo()` failing now releases the source it claimed, and hides
the video widget only under the same `mediaWidget`/`mediaClose` guards
the deferred release uses — previously it could hide a label belonging
to an earlier clip.
- `src/dlgTriggerEditor.cpp` is here for one guard:
`runScheduledCleanReset()` repopulates itself from a `Host` that a
profile teardown has already destroyed. Unrelated to media, but it
crashed the media tests once they started running.
Behaviour worth knowing about when reviewing:
- `sysMediaFinished` now fires for a failed load and for a stop issued
mid-load, where nothing fired before. It is suppressed when the source
has already been released, where it would only have carried an empty
file name and path.
- `purgeMediaCache()` returns `false` when the directory could not be
fully removed, instead of always returning `true`.
- The closing closed caption is suppressed between the passes of a
looping track, and printed by `stopAllMediaPlayers()`, which releases
synchronously.
- `TMedia` gains three read-only diagnostics used by the tests —
`playersHoldingSource()`, `mediaPlayerCount()` and
`playersInPlayingState()`. A deferred release is otherwise unobservable:
`playingMedia()` has already dropped the player, the caption needs
captions enabled, and the video signal needs a widget.
Tests: new slots for a finite `loops=N` track, an explicit stop, an
unplayable source and a reused player. `probeBackend()` measures what
the backend can demonstrate — whether it starts playback at all, decodes
to `EndOfMedia`, orders `EndOfMedia` against `StoppedState`, starts
synchronously, and reports an undecodable file — and each test skips on
the capabilities it needs, printing what was measured.
`QT_MEDIA_BACKEND` is pinned to whatever `main.cpp` ships per platform,
since `QTEST_MAIN` does not run `main.cpp` and the tests were otherwise
exercising Qt's default backend rather than the one users get.
#### Motivation for adding to Mudlet
Both silent-failure cases are real and user-visible. A game sending a
filename Qt cannot decode, or a file gone from the media cache, would
kill the sound and leak the player's source while reporting nothing; and
`stopMusic()` shortly after `playMusic()` would leak the source every
time on Windows and Linux. A script chaining tracks off
`sysMediaFinished` waited forever in both cases.
They share a root cause with #9566: the deferred release could not tell
what the player was doing when its turn came around. The
`claimSource()`/`continuePlaying()`/`releaseSource()` encapsulation is
the durable part — it turns "remember to bump the counter" from a
convention into something the API does for you.
The test work matters as much as the fixes. The #9566 regression guard
was skipping on every CI job, so it was protecting nothing; both bugs
above were caught only once it actually ran.
#### Other info (issues closed, discussion etc)
Follow-up to #9569 / #9566. No issue number of its own.
Testing notes: the full functional suite passes — 66/66 on Linux, and
the media suite is green on the Ubuntu, Windows and both macOS jobs.
Backends differ in what they can demonstrate, so some media tests skip
by design. Under the environment ctest uses, six of the seven skip on
macOS: the `darwin` backend starts playback synchronously and delivers
`EndOfMedia` before `StoppedState`, so it can stage neither the claim
race nor the #9566 ordering, and it does not decode under
`QT_QPA_PLATFORM=offscreen`. Those paths run on the Ubuntu and Windows
FFmpeg builds, which is where both bugs in this PR were caught. Every
skip prints what the backend could not demonstrate and why, so an inert
guard is visible rather than silent.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-05 00:34:43 -04:00
Fix looping sounds playing only once (#9569)
#### Brief overview of PR changes/additions
Looping media (`loops=-1`) plays only once on Qt's FFmpeg multimedia
backend.
That backend ends a track by emitting `StoppedState` **first** and
`EndOfMedia` **second**, and it skips the `EndOfMedia` notification when
the playback engine was destroyed in between:
```cpp
if (currentPlaybackEngine)
stateChanged(QMediaPlayer::StoppedState);
if (currentPlaybackEngine)
mediaStatusChanged(QMediaPlayer::EndOfMedia);
```
Mudlet restarts a loop from its `EndOfMedia` handler, but
`TMedia::handlePlayerPlaybackStateChanged()` called `setSource(QUrl())`
immediately on `StoppedState`. That destroyed the engine, so
`EndOfMedia` never arrived and the loop died after one pass.
This defers the cleanup by one event-loop turn and re-checks the
playback state, so a loop restart can claim the player first while a
genuinely stopped player is still torn down.
#### Motivation for adding to Mudlet
Fixes #9566. Ambient/background music tracks stop after a single pass,
which affects any game using `Client.Media.Play` with `loops: -1`, MSP,
or `playMusicFile()`.
The immediate source clearing was introduced by #9237 as a
resource-cleanup measure. That cleanup is preserved here — it just runs
one event-loop turn later, and only if the player is still stopped.
#### Other info (issues closed, discussion etc)
Fixes #9566. Regression introduced by #9237 (merged 2026-04-29), so 4.21
and 4.22 are affected; 4.20.1 and earlier are not.
**Verified on Windows against the FFmpeg backend.** The bug reproduces
on unfixed code there, and this change resolves it without regressing
the cleanup that #9237 added.
It is worth being explicit that this could not be confirmed on macOS:
that platform ships `libdarwinmediaplugin`, which emits `EndOfMedia`
*before* `StoppedState` — the reverse ordering — so the loop restarts
before any cleanup runs and the bug cannot occur there at all. Reverting
the fix on macOS produced identical behaviour. The fix is therefore
specific to backends with the FFmpeg ordering, which is where the issue
was reported.
A functional test is included
(`test/functional_tests/TMediaLoopTest.cpp`) covering both halves of the
contract: a looping track must survive the stop/restart cycle, and a
one-shot track must still be cleaned up. It probes the backend before
asserting and **skips** unless that backend can both finish a clip and
emit `StoppedState` before `EndOfMedia`. Without that guard the looping
assertion passes on broken code, which is exactly what happened on
macOS. Expected results:
- FFmpeg backend → runs and asserts
- macOS darwin backend → skips (cannot exhibit the bug)
- Anywhere media stalls under `QT_QPA_PLATFORM=offscreen`, which the
functional suite forces → skips
So it may well skip in CI; that is intentional and preferable to a test
that always passes.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-02 13:11:37 -04:00
if ( ! stoppedCameFirst ) {
mWrongOrderReason = qsl ( " This Qt Multimedia backend emits EndOfMedia before StoppedState, so the loop restarts before any cleanup runs and issue #9566 cannot occur here. Needs a "
" StoppedState-first backend such as Qt's FFmpeg one. " ) ;
}
Fix: Sounds going silent when a file fails to load (#9612)
#### Brief overview of PR changes/additions
Follow-up hardening on top of #9569. Two ways a media player could end
up silent while still holding its source, plus the crash and the test
gaps found chasing them.
**A track that fails to load.** Nothing in `TMedia` listened for
`QMediaPlayer::errorOccurred`. A player that was already stopped when
its source failed reports no playback state change — and that signal is
what ends a playback, releases the source and raises `sysMediaFinished`.
The track fell silent holding a file nothing would ever release. The
error is now acted on.
**A track stopped while it is still loading.** Qt already considers an
unstarted player stopped, so `stop()` draws no state change out of one
mid-load, with the same result. This is not a narrow race: on an
asynchronous backend the Linux and Windows runners hit it every time.
`stopMedia()` now ends such a playback itself instead of waiting for a
report that is never coming.
Around those:
- The deferred source release is a single function shared by the stop,
error and teardown paths. Whether the player's own state gets a say
differs between them, so callers pass a `PlaybackEnd`: a stop needs it,
because a restart may be in flight and a player loading its next source
looks identical to a stopped one; a failure must *not* have it, because
a backend can report `PlayingState` for media it has just failed to
load.
- Re-sourcing a player goes through `claimSource()`, `continuePlaying()`
and `releaseSource()`, so the generation bump that tells a pending
release the track has moved on cannot be forgotten at a call site. A
missed bump is what let an earlier revision of #9569 clear the source of
a track that had just been restarted.
- `stopMedia()` empties the playlist, so an explicit stop cannot leave a
loop armed to restart itself from the `EndOfMedia` handler.
- `setupVideo()` failing now releases the source it claimed, and hides
the video widget only under the same `mediaWidget`/`mediaClose` guards
the deferred release uses — previously it could hide a label belonging
to an earlier clip.
- `src/dlgTriggerEditor.cpp` is here for one guard:
`runScheduledCleanReset()` repopulates itself from a `Host` that a
profile teardown has already destroyed. Unrelated to media, but it
crashed the media tests once they started running.
Behaviour worth knowing about when reviewing:
- `sysMediaFinished` now fires for a failed load and for a stop issued
mid-load, where nothing fired before. It is suppressed when the source
has already been released, where it would only have carried an empty
file name and path.
- `purgeMediaCache()` returns `false` when the directory could not be
fully removed, instead of always returning `true`.
- The closing closed caption is suppressed between the passes of a
looping track, and printed by `stopAllMediaPlayers()`, which releases
synchronously.
- `TMedia` gains three read-only diagnostics used by the tests —
`playersHoldingSource()`, `mediaPlayerCount()` and
`playersInPlayingState()`. A deferred release is otherwise unobservable:
`playingMedia()` has already dropped the player, the caption needs
captions enabled, and the video signal needs a widget.
Tests: new slots for a finite `loops=N` track, an explicit stop, an
unplayable source and a reused player. `probeBackend()` measures what
the backend can demonstrate — whether it starts playback at all, decodes
to `EndOfMedia`, orders `EndOfMedia` against `StoppedState`, starts
synchronously, and reports an undecodable file — and each test skips on
the capabilities it needs, printing what was measured.
`QT_MEDIA_BACKEND` is pinned to whatever `main.cpp` ships per platform,
since `QTEST_MAIN` does not run `main.cpp` and the tests were otherwise
exercising Qt's default backend rather than the one users get.
#### Motivation for adding to Mudlet
Both silent-failure cases are real and user-visible. A game sending a
filename Qt cannot decode, or a file gone from the media cache, would
kill the sound and leak the player's source while reporting nothing; and
`stopMusic()` shortly after `playMusic()` would leak the source every
time on Windows and Linux. A script chaining tracks off
`sysMediaFinished` waited forever in both cases.
They share a root cause with #9566: the deferred release could not tell
what the player was doing when its turn came around. The
`claimSource()`/`continuePlaying()`/`releaseSource()` encapsulation is
the durable part — it turns "remember to bump the counter" from a
convention into something the API does for you.
The test work matters as much as the fixes. The #9566 regression guard
was skipping on every CI job, so it was protecting nothing; both bugs
above were caught only once it actually ran.
#### Other info (issues closed, discussion etc)
Follow-up to #9569 / #9566. No issue number of its own.
Testing notes: the full functional suite passes — 66/66 on Linux, and
the media suite is green on the Ubuntu, Windows and both macOS jobs.
Backends differ in what they can demonstrate, so some media tests skip
by design. Under the environment ctest uses, six of the seven skip on
macOS: the `darwin` backend starts playback synchronously and delivers
`EndOfMedia` before `StoppedState`, so it can stage neither the claim
race nor the #9566 ordering, and it does not decode under
`QT_QPA_PLATFORM=offscreen`. Those paths run on the Ubuntu and Windows
FFmpeg builds, which is where both bugs in this PR were caught. Every
skip prints what the backend could not demonstrate and why, so an inert
guard is visible rather than silent.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-05 00:34:43 -04:00
// Only worth asking of a backend that got this far. Assumed, not measured: one that
// cannot finish a valid clip is taken not to reject an invalid one either.
probeLoadFailureReporting ( ) ;
}
// Whether an undecodable file is reported as an error at all. A backend that stays silent
// gives TMedia nothing to act on, so the release it cannot schedule cannot be asserted.
void probeLoadFailureReporting ( )
{
const QString path = qsl ( " %1/unplayable.wav " ) . arg ( mProbeDir . path ( ) ) ;
QFile file ( path ) ;
if ( ! file . open ( QIODevice : : WriteOnly ) ) {
QFAIL ( " Could not write the unplayable probe clip. " ) ;
}
file . write ( QByteArray ( " not a WAV file, and not decodable as anything else " ) ) ;
file . close ( ) ;
QMediaPlayer probe ;
auto * output = new QAudioOutput ( & probe ) ;
output - > setMuted ( true ) ;
probe . setAudioOutput ( output ) ;
bool sawError = false ;
connect ( & probe , & QMediaPlayer : : errorOccurred , this , [ & ] ( QMediaPlayer : : Error error , const QString & ) {
if ( error ! = QMediaPlayer : : NoError ) {
sawError = true ;
}
} ) ;
probe . setSource ( QUrl : : fromLocalFile ( path ) ) ;
probe . play ( ) ;
const bool reported = QTest : : qWaitFor (
[ & ] ( ) {
return sawError ;
} ,
QDeadlineTimer ( 10 s ) ) ;
probe . stop ( ) ;
if ( ! reported ) {
mNoLoadErrorReason = qsl ( " This Qt Multimedia backend does not report an error for an undecodable file within 10s, so there is no failure for TMedia to act on. Backend: \" %1 \" , final "
" media status: %2. " )
. arg ( QString : : fromLocal8Bit ( qgetenv ( " QT_MEDIA_BACKEND " ) ) , QString : : number ( static_cast < int > ( probe . mediaStatus ( ) ) ) ) ;
Fix looping sounds playing only once (#9569)
#### Brief overview of PR changes/additions
Looping media (`loops=-1`) plays only once on Qt's FFmpeg multimedia
backend.
That backend ends a track by emitting `StoppedState` **first** and
`EndOfMedia` **second**, and it skips the `EndOfMedia` notification when
the playback engine was destroyed in between:
```cpp
if (currentPlaybackEngine)
stateChanged(QMediaPlayer::StoppedState);
if (currentPlaybackEngine)
mediaStatusChanged(QMediaPlayer::EndOfMedia);
```
Mudlet restarts a loop from its `EndOfMedia` handler, but
`TMedia::handlePlayerPlaybackStateChanged()` called `setSource(QUrl())`
immediately on `StoppedState`. That destroyed the engine, so
`EndOfMedia` never arrived and the loop died after one pass.
This defers the cleanup by one event-loop turn and re-checks the
playback state, so a loop restart can claim the player first while a
genuinely stopped player is still torn down.
#### Motivation for adding to Mudlet
Fixes #9566. Ambient/background music tracks stop after a single pass,
which affects any game using `Client.Media.Play` with `loops: -1`, MSP,
or `playMusicFile()`.
The immediate source clearing was introduced by #9237 as a
resource-cleanup measure. That cleanup is preserved here — it just runs
one event-loop turn later, and only if the player is still stopped.
#### Other info (issues closed, discussion etc)
Fixes #9566. Regression introduced by #9237 (merged 2026-04-29), so 4.21
and 4.22 are affected; 4.20.1 and earlier are not.
**Verified on Windows against the FFmpeg backend.** The bug reproduces
on unfixed code there, and this change resolves it without regressing
the cleanup that #9237 added.
It is worth being explicit that this could not be confirmed on macOS:
that platform ships `libdarwinmediaplugin`, which emits `EndOfMedia`
*before* `StoppedState` — the reverse ordering — so the loop restarts
before any cleanup runs and the bug cannot occur there at all. Reverting
the fix on macOS produced identical behaviour. The fix is therefore
specific to backends with the FFmpeg ordering, which is where the issue
was reported.
A functional test is included
(`test/functional_tests/TMediaLoopTest.cpp`) covering both halves of the
contract: a looping track must survive the stop/restart cycle, and a
one-shot track must still be cleaned up. It probes the backend before
asserting and **skips** unless that backend can both finish a clip and
emit `StoppedState` before `EndOfMedia`. Without that guard the looping
assertion passes on broken code, which is exactly what happened on
macOS. Expected results:
- FFmpeg backend → runs and asserts
- macOS darwin backend → skips (cannot exhibit the bug)
- Anywhere media stalls under `QT_QPA_PLATFORM=offscreen`, which the
functional suite forces → skips
So it may well skip in CI; that is intentional and preferable to a test
that always passes.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-02 13:11:37 -04:00
}
}
TMediaData clipData ( const QString & fileName ) const
{
TMediaData data ;
data . setMediaProtocol ( TMediaData : : MediaProtocolAPI ) ;
data . setMediaType ( TMediaData : : MediaTypeMusic ) ;
data . setMediaInput ( TMediaData : : MediaInputFile ) ;
data . setMediaFileName ( fileName ) ;
data . setMediaVolume ( 1 ) ; // audible enough to play, quiet enough not to disturb a desktop run
return data ;
}
Fix: Sounds going silent when a file fails to load (#9612)
#### Brief overview of PR changes/additions
Follow-up hardening on top of #9569. Two ways a media player could end
up silent while still holding its source, plus the crash and the test
gaps found chasing them.
**A track that fails to load.** Nothing in `TMedia` listened for
`QMediaPlayer::errorOccurred`. A player that was already stopped when
its source failed reports no playback state change — and that signal is
what ends a playback, releases the source and raises `sysMediaFinished`.
The track fell silent holding a file nothing would ever release. The
error is now acted on.
**A track stopped while it is still loading.** Qt already considers an
unstarted player stopped, so `stop()` draws no state change out of one
mid-load, with the same result. This is not a narrow race: on an
asynchronous backend the Linux and Windows runners hit it every time.
`stopMedia()` now ends such a playback itself instead of waiting for a
report that is never coming.
Around those:
- The deferred source release is a single function shared by the stop,
error and teardown paths. Whether the player's own state gets a say
differs between them, so callers pass a `PlaybackEnd`: a stop needs it,
because a restart may be in flight and a player loading its next source
looks identical to a stopped one; a failure must *not* have it, because
a backend can report `PlayingState` for media it has just failed to
load.
- Re-sourcing a player goes through `claimSource()`, `continuePlaying()`
and `releaseSource()`, so the generation bump that tells a pending
release the track has moved on cannot be forgotten at a call site. A
missed bump is what let an earlier revision of #9569 clear the source of
a track that had just been restarted.
- `stopMedia()` empties the playlist, so an explicit stop cannot leave a
loop armed to restart itself from the `EndOfMedia` handler.
- `setupVideo()` failing now releases the source it claimed, and hides
the video widget only under the same `mediaWidget`/`mediaClose` guards
the deferred release uses — previously it could hide a label belonging
to an earlier clip.
- `src/dlgTriggerEditor.cpp` is here for one guard:
`runScheduledCleanReset()` repopulates itself from a `Host` that a
profile teardown has already destroyed. Unrelated to media, but it
crashed the media tests once they started running.
Behaviour worth knowing about when reviewing:
- `sysMediaFinished` now fires for a failed load and for a stop issued
mid-load, where nothing fired before. It is suppressed when the source
has already been released, where it would only have carried an empty
file name and path.
- `purgeMediaCache()` returns `false` when the directory could not be
fully removed, instead of always returning `true`.
- The closing closed caption is suppressed between the passes of a
looping track, and printed by `stopAllMediaPlayers()`, which releases
synchronously.
- `TMedia` gains three read-only diagnostics used by the tests —
`playersHoldingSource()`, `mediaPlayerCount()` and
`playersInPlayingState()`. A deferred release is otherwise unobservable:
`playingMedia()` has already dropped the player, the caption needs
captions enabled, and the video signal needs a widget.
Tests: new slots for a finite `loops=N` track, an explicit stop, an
unplayable source and a reused player. `probeBackend()` measures what
the backend can demonstrate — whether it starts playback at all, decodes
to `EndOfMedia`, orders `EndOfMedia` against `StoppedState`, starts
synchronously, and reports an undecodable file — and each test skips on
the capabilities it needs, printing what was measured.
`QT_MEDIA_BACKEND` is pinned to whatever `main.cpp` ships per platform,
since `QTEST_MAIN` does not run `main.cpp` and the tests were otherwise
exercising Qt's default backend rather than the one users get.
#### Motivation for adding to Mudlet
Both silent-failure cases are real and user-visible. A game sending a
filename Qt cannot decode, or a file gone from the media cache, would
kill the sound and leak the player's source while reporting nothing; and
`stopMusic()` shortly after `playMusic()` would leak the source every
time on Windows and Linux. A script chaining tracks off
`sysMediaFinished` waited forever in both cases.
They share a root cause with #9566: the deferred release could not tell
what the player was doing when its turn came around. The
`claimSource()`/`continuePlaying()`/`releaseSource()` encapsulation is
the durable part — it turns "remember to bump the counter" from a
convention into something the API does for you.
The test work matters as much as the fixes. The #9566 regression guard
was skipping on every CI job, so it was protecting nothing; both bugs
above were caught only once it actually ran.
#### Other info (issues closed, discussion etc)
Follow-up to #9569 / #9566. No issue number of its own.
Testing notes: the full functional suite passes — 66/66 on Linux, and
the media suite is green on the Ubuntu, Windows and both macOS jobs.
Backends differ in what they can demonstrate, so some media tests skip
by design. Under the environment ctest uses, six of the seven skip on
macOS: the `darwin` backend starts playback synchronously and delivers
`EndOfMedia` before `StoppedState`, so it can stage neither the claim
race nor the #9566 ordering, and it does not decode under
`QT_QPA_PLATFORM=offscreen`. Those paths run on the Ubuntu and Windows
FFmpeg builds, which is where both bugs in this PR were caught. Every
skip prints what the backend could not demonstrate and why, so an inert
guard is visible rather than silent.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-05 00:34:43 -04:00
// TMedia reports a player only while it is actually playing, or still loading - the
// carve-out that lets a stalled backend look busy, and the observable this turns on.
Fix looping sounds playing only once (#9569)
#### Brief overview of PR changes/additions
Looping media (`loops=-1`) plays only once on Qt's FFmpeg multimedia
backend.
That backend ends a track by emitting `StoppedState` **first** and
`EndOfMedia` **second**, and it skips the `EndOfMedia` notification when
the playback engine was destroyed in between:
```cpp
if (currentPlaybackEngine)
stateChanged(QMediaPlayer::StoppedState);
if (currentPlaybackEngine)
mediaStatusChanged(QMediaPlayer::EndOfMedia);
```
Mudlet restarts a loop from its `EndOfMedia` handler, but
`TMedia::handlePlayerPlaybackStateChanged()` called `setSource(QUrl())`
immediately on `StoppedState`. That destroyed the engine, so
`EndOfMedia` never arrived and the loop died after one pass.
This defers the cleanup by one event-loop turn and re-checks the
playback state, so a loop restart can claim the player first while a
genuinely stopped player is still torn down.
#### Motivation for adding to Mudlet
Fixes #9566. Ambient/background music tracks stop after a single pass,
which affects any game using `Client.Media.Play` with `loops: -1`, MSP,
or `playMusicFile()`.
The immediate source clearing was introduced by #9237 as a
resource-cleanup measure. That cleanup is preserved here — it just runs
one event-loop turn later, and only if the player is still stopped.
#### Other info (issues closed, discussion etc)
Fixes #9566. Regression introduced by #9237 (merged 2026-04-29), so 4.21
and 4.22 are affected; 4.20.1 and earlier are not.
**Verified on Windows against the FFmpeg backend.** The bug reproduces
on unfixed code there, and this change resolves it without regressing
the cleanup that #9237 added.
It is worth being explicit that this could not be confirmed on macOS:
that platform ships `libdarwinmediaplugin`, which emits `EndOfMedia`
*before* `StoppedState` — the reverse ordering — so the loop restarts
before any cleanup runs and the bug cannot occur there at all. Reverting
the fix on macOS produced identical behaviour. The fix is therefore
specific to backends with the FFmpeg ordering, which is where the issue
was reported.
A functional test is included
(`test/functional_tests/TMediaLoopTest.cpp`) covering both halves of the
contract: a looping track must survive the stop/restart cycle, and a
one-shot track must still be cleaned up. It probes the backend before
asserting and **skips** unless that backend can both finish a clip and
emit `StoppedState` before `EndOfMedia`. Without that guard the looping
assertion passes on broken code, which is exactly what happened on
macOS. Expected results:
- FFmpeg backend → runs and asserts
- macOS darwin backend → skips (cannot exhibit the bug)
- Anywhere media stalls under `QT_QPA_PLATFORM=offscreen`, which the
functional suite forces → skips
So it may well skip in CI; that is intentional and preferable to a test
that always passes.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-02 13:11:37 -04:00
bool playing ( TMedia * media , const QString & fileName ) const
{
TMediaData criteria = clipData ( fileName ) ;
return ! media - > playingMedia ( criteria ) . isEmpty ( ) ;
}
bool waitForPlaying ( TMedia * media , const QString & fileName , std : : chrono : : milliseconds timeout = 10 s )
{
return QTest : : qWaitFor (
[ & ] ( ) {
return playing ( media , fileName ) ;
} ,
QDeadlineTimer ( timeout ) ) ;
}
Fix: Sounds going silent when a file fails to load (#9612)
#### Brief overview of PR changes/additions
Follow-up hardening on top of #9569. Two ways a media player could end
up silent while still holding its source, plus the crash and the test
gaps found chasing them.
**A track that fails to load.** Nothing in `TMedia` listened for
`QMediaPlayer::errorOccurred`. A player that was already stopped when
its source failed reports no playback state change — and that signal is
what ends a playback, releases the source and raises `sysMediaFinished`.
The track fell silent holding a file nothing would ever release. The
error is now acted on.
**A track stopped while it is still loading.** Qt already considers an
unstarted player stopped, so `stop()` draws no state change out of one
mid-load, with the same result. This is not a narrow race: on an
asynchronous backend the Linux and Windows runners hit it every time.
`stopMedia()` now ends such a playback itself instead of waiting for a
report that is never coming.
Around those:
- The deferred source release is a single function shared by the stop,
error and teardown paths. Whether the player's own state gets a say
differs between them, so callers pass a `PlaybackEnd`: a stop needs it,
because a restart may be in flight and a player loading its next source
looks identical to a stopped one; a failure must *not* have it, because
a backend can report `PlayingState` for media it has just failed to
load.
- Re-sourcing a player goes through `claimSource()`, `continuePlaying()`
and `releaseSource()`, so the generation bump that tells a pending
release the track has moved on cannot be forgotten at a call site. A
missed bump is what let an earlier revision of #9569 clear the source of
a track that had just been restarted.
- `stopMedia()` empties the playlist, so an explicit stop cannot leave a
loop armed to restart itself from the `EndOfMedia` handler.
- `setupVideo()` failing now releases the source it claimed, and hides
the video widget only under the same `mediaWidget`/`mediaClose` guards
the deferred release uses — previously it could hide a label belonging
to an earlier clip.
- `src/dlgTriggerEditor.cpp` is here for one guard:
`runScheduledCleanReset()` repopulates itself from a `Host` that a
profile teardown has already destroyed. Unrelated to media, but it
crashed the media tests once they started running.
Behaviour worth knowing about when reviewing:
- `sysMediaFinished` now fires for a failed load and for a stop issued
mid-load, where nothing fired before. It is suppressed when the source
has already been released, where it would only have carried an empty
file name and path.
- `purgeMediaCache()` returns `false` when the directory could not be
fully removed, instead of always returning `true`.
- The closing closed caption is suppressed between the passes of a
looping track, and printed by `stopAllMediaPlayers()`, which releases
synchronously.
- `TMedia` gains three read-only diagnostics used by the tests —
`playersHoldingSource()`, `mediaPlayerCount()` and
`playersInPlayingState()`. A deferred release is otherwise unobservable:
`playingMedia()` has already dropped the player, the caption needs
captions enabled, and the video signal needs a widget.
Tests: new slots for a finite `loops=N` track, an explicit stop, an
unplayable source and a reused player. `probeBackend()` measures what
the backend can demonstrate — whether it starts playback at all, decodes
to `EndOfMedia`, orders `EndOfMedia` against `StoppedState`, starts
synchronously, and reports an undecodable file — and each test skips on
the capabilities it needs, printing what was measured.
`QT_MEDIA_BACKEND` is pinned to whatever `main.cpp` ships per platform,
since `QTEST_MAIN` does not run `main.cpp` and the tests were otherwise
exercising Qt's default backend rather than the one users get.
#### Motivation for adding to Mudlet
Both silent-failure cases are real and user-visible. A game sending a
filename Qt cannot decode, or a file gone from the media cache, would
kill the sound and leak the player's source while reporting nothing; and
`stopMusic()` shortly after `playMusic()` would leak the source every
time on Windows and Linux. A script chaining tracks off
`sysMediaFinished` waited forever in both cases.
They share a root cause with #9566: the deferred release could not tell
what the player was doing when its turn came around. The
`claimSource()`/`continuePlaying()`/`releaseSource()` encapsulation is
the durable part — it turns "remember to bump the counter" from a
convention into something the API does for you.
The test work matters as much as the fixes. The #9566 regression guard
was skipping on every CI job, so it was protecting nothing; both bugs
above were caught only once it actually ran.
#### Other info (issues closed, discussion etc)
Follow-up to #9569 / #9566. No issue number of its own.
Testing notes: the full functional suite passes — 66/66 on Linux, and
the media suite is green on the Ubuntu, Windows and both macOS jobs.
Backends differ in what they can demonstrate, so some media tests skip
by design. Under the environment ctest uses, six of the seven skip on
macOS: the `darwin` backend starts playback synchronously and delivers
`EndOfMedia` before `StoppedState`, so it can stage neither the claim
race nor the #9566 ordering, and it does not decode under
`QT_QPA_PLATFORM=offscreen`. Those paths run on the Ubuntu and Windows
FFmpeg builds, which is where both bugs in this PR were caught. Every
skip prints what the backend could not demonstrate and why, so an inert
guard is visible rather than silent.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-05 00:34:43 -04:00
// Stronger than waitForPlaying(): a player that is still loading counts as playing to
// TMedia, and it is not yet stoppable in the way a started one is - stopping it produces no
// playback state change, and getMediaPlayer() will not hand it to another track.
bool waitForPlaybackStarted ( TMedia * media , const QString & fileName , std : : chrono : : milliseconds timeout = 10 s )
{
return QTest : : qWaitFor (
[ & ] ( ) {
return playing ( media , fileName ) & & media - > playersInPlayingState ( ) > 0 ;
} ,
QDeadlineTimer ( timeout ) ) ;
}
// Passes the file-name checks in TMedia::play(), so it reaches the backend and fails
// there, so the error path is reached the way a real undecodable file would reach it.
QString writeUnplayableClip ( const QString & fileName ) const { return writeClip ( fileName , QByteArray ( " not a WAV file, and not decodable as anything else " ) ) ; }
// Writes a clip into the profile media directory, returning {} if that fails.
QString writeClip ( const QString & fileName , const QByteArray & contents = wavBytes ( ) ) const
Fix looping sounds playing only once (#9569)
#### Brief overview of PR changes/additions
Looping media (`loops=-1`) plays only once on Qt's FFmpeg multimedia
backend.
That backend ends a track by emitting `StoppedState` **first** and
`EndOfMedia` **second**, and it skips the `EndOfMedia` notification when
the playback engine was destroyed in between:
```cpp
if (currentPlaybackEngine)
stateChanged(QMediaPlayer::StoppedState);
if (currentPlaybackEngine)
mediaStatusChanged(QMediaPlayer::EndOfMedia);
```
Mudlet restarts a loop from its `EndOfMedia` handler, but
`TMedia::handlePlayerPlaybackStateChanged()` called `setSource(QUrl())`
immediately on `StoppedState`. That destroyed the engine, so
`EndOfMedia` never arrived and the loop died after one pass.
This defers the cleanup by one event-loop turn and re-checks the
playback state, so a loop restart can claim the player first while a
genuinely stopped player is still torn down.
#### Motivation for adding to Mudlet
Fixes #9566. Ambient/background music tracks stop after a single pass,
which affects any game using `Client.Media.Play` with `loops: -1`, MSP,
or `playMusicFile()`.
The immediate source clearing was introduced by #9237 as a
resource-cleanup measure. That cleanup is preserved here — it just runs
one event-loop turn later, and only if the player is still stopped.
#### Other info (issues closed, discussion etc)
Fixes #9566. Regression introduced by #9237 (merged 2026-04-29), so 4.21
and 4.22 are affected; 4.20.1 and earlier are not.
**Verified on Windows against the FFmpeg backend.** The bug reproduces
on unfixed code there, and this change resolves it without regressing
the cleanup that #9237 added.
It is worth being explicit that this could not be confirmed on macOS:
that platform ships `libdarwinmediaplugin`, which emits `EndOfMedia`
*before* `StoppedState` — the reverse ordering — so the loop restarts
before any cleanup runs and the bug cannot occur there at all. Reverting
the fix on macOS produced identical behaviour. The fix is therefore
specific to backends with the FFmpeg ordering, which is where the issue
was reported.
A functional test is included
(`test/functional_tests/TMediaLoopTest.cpp`) covering both halves of the
contract: a looping track must survive the stop/restart cycle, and a
one-shot track must still be cleaned up. It probes the backend before
asserting and **skips** unless that backend can both finish a clip and
emit `StoppedState` before `EndOfMedia`. Without that guard the looping
assertion passes on broken code, which is exactly what happened on
macOS. Expected results:
- FFmpeg backend → runs and asserts
- macOS darwin backend → skips (cannot exhibit the bug)
- Anywhere media stalls under `QT_QPA_PLATFORM=offscreen`, which the
functional suite forces → skips
So it may well skip in CI; that is intentional and preferable to a test
that always passes.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-02 13:11:37 -04:00
{
const QString mediaPath = mudlet : : getMudletPath ( enums : : profileMediaPath , mHostname ) ;
if ( ! QDir ( ) . mkpath ( mediaPath ) ) {
QTest : : qFail ( " Could not create the profile media directory. " , __FILE__ , __LINE__ ) ;
return { } ;
}
QFile file ( qsl ( " %1/%2 " ) . arg ( mediaPath , fileName ) ) ;
if ( ! file . open ( QIODevice : : WriteOnly ) ) {
QTest : : qFail ( " Could not write the test media file. " , __FILE__ , __LINE__ ) ;
return { } ;
}
Fix: Sounds going silent when a file fails to load (#9612)
#### Brief overview of PR changes/additions
Follow-up hardening on top of #9569. Two ways a media player could end
up silent while still holding its source, plus the crash and the test
gaps found chasing them.
**A track that fails to load.** Nothing in `TMedia` listened for
`QMediaPlayer::errorOccurred`. A player that was already stopped when
its source failed reports no playback state change — and that signal is
what ends a playback, releases the source and raises `sysMediaFinished`.
The track fell silent holding a file nothing would ever release. The
error is now acted on.
**A track stopped while it is still loading.** Qt already considers an
unstarted player stopped, so `stop()` draws no state change out of one
mid-load, with the same result. This is not a narrow race: on an
asynchronous backend the Linux and Windows runners hit it every time.
`stopMedia()` now ends such a playback itself instead of waiting for a
report that is never coming.
Around those:
- The deferred source release is a single function shared by the stop,
error and teardown paths. Whether the player's own state gets a say
differs between them, so callers pass a `PlaybackEnd`: a stop needs it,
because a restart may be in flight and a player loading its next source
looks identical to a stopped one; a failure must *not* have it, because
a backend can report `PlayingState` for media it has just failed to
load.
- Re-sourcing a player goes through `claimSource()`, `continuePlaying()`
and `releaseSource()`, so the generation bump that tells a pending
release the track has moved on cannot be forgotten at a call site. A
missed bump is what let an earlier revision of #9569 clear the source of
a track that had just been restarted.
- `stopMedia()` empties the playlist, so an explicit stop cannot leave a
loop armed to restart itself from the `EndOfMedia` handler.
- `setupVideo()` failing now releases the source it claimed, and hides
the video widget only under the same `mediaWidget`/`mediaClose` guards
the deferred release uses — previously it could hide a label belonging
to an earlier clip.
- `src/dlgTriggerEditor.cpp` is here for one guard:
`runScheduledCleanReset()` repopulates itself from a `Host` that a
profile teardown has already destroyed. Unrelated to media, but it
crashed the media tests once they started running.
Behaviour worth knowing about when reviewing:
- `sysMediaFinished` now fires for a failed load and for a stop issued
mid-load, where nothing fired before. It is suppressed when the source
has already been released, where it would only have carried an empty
file name and path.
- `purgeMediaCache()` returns `false` when the directory could not be
fully removed, instead of always returning `true`.
- The closing closed caption is suppressed between the passes of a
looping track, and printed by `stopAllMediaPlayers()`, which releases
synchronously.
- `TMedia` gains three read-only diagnostics used by the tests —
`playersHoldingSource()`, `mediaPlayerCount()` and
`playersInPlayingState()`. A deferred release is otherwise unobservable:
`playingMedia()` has already dropped the player, the caption needs
captions enabled, and the video signal needs a widget.
Tests: new slots for a finite `loops=N` track, an explicit stop, an
unplayable source and a reused player. `probeBackend()` measures what
the backend can demonstrate — whether it starts playback at all, decodes
to `EndOfMedia`, orders `EndOfMedia` against `StoppedState`, starts
synchronously, and reports an undecodable file — and each test skips on
the capabilities it needs, printing what was measured.
`QT_MEDIA_BACKEND` is pinned to whatever `main.cpp` ships per platform,
since `QTEST_MAIN` does not run `main.cpp` and the tests were otherwise
exercising Qt's default backend rather than the one users get.
#### Motivation for adding to Mudlet
Both silent-failure cases are real and user-visible. A game sending a
filename Qt cannot decode, or a file gone from the media cache, would
kill the sound and leak the player's source while reporting nothing; and
`stopMusic()` shortly after `playMusic()` would leak the source every
time on Windows and Linux. A script chaining tracks off
`sysMediaFinished` waited forever in both cases.
They share a root cause with #9566: the deferred release could not tell
what the player was doing when its turn came around. The
`claimSource()`/`continuePlaying()`/`releaseSource()` encapsulation is
the durable part — it turns "remember to bump the counter" from a
convention into something the API does for you.
The test work matters as much as the fixes. The #9566 regression guard
was skipping on every CI job, so it was protecting nothing; both bugs
above were caught only once it actually ran.
#### Other info (issues closed, discussion etc)
Follow-up to #9569 / #9566. No issue number of its own.
Testing notes: the full functional suite passes — 66/66 on Linux, and
the media suite is green on the Ubuntu, Windows and both macOS jobs.
Backends differ in what they can demonstrate, so some media tests skip
by design. Under the environment ctest uses, six of the seven skip on
macOS: the `darwin` backend starts playback synchronously and delivers
`EndOfMedia` before `StoppedState`, so it can stage neither the claim
race nor the #9566 ordering, and it does not decode under
`QT_QPA_PLATFORM=offscreen`. Those paths run on the Ubuntu and Windows
FFmpeg builds, which is where both bugs in this PR were caught. Every
skip prints what the backend could not demonstrate and why, so an inert
guard is visible rather than silent.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-05 00:34:43 -04:00
file . write ( contents ) ;
Fix looping sounds playing only once (#9569)
#### Brief overview of PR changes/additions
Looping media (`loops=-1`) plays only once on Qt's FFmpeg multimedia
backend.
That backend ends a track by emitting `StoppedState` **first** and
`EndOfMedia` **second**, and it skips the `EndOfMedia` notification when
the playback engine was destroyed in between:
```cpp
if (currentPlaybackEngine)
stateChanged(QMediaPlayer::StoppedState);
if (currentPlaybackEngine)
mediaStatusChanged(QMediaPlayer::EndOfMedia);
```
Mudlet restarts a loop from its `EndOfMedia` handler, but
`TMedia::handlePlayerPlaybackStateChanged()` called `setSource(QUrl())`
immediately on `StoppedState`. That destroyed the engine, so
`EndOfMedia` never arrived and the loop died after one pass.
This defers the cleanup by one event-loop turn and re-checks the
playback state, so a loop restart can claim the player first while a
genuinely stopped player is still torn down.
#### Motivation for adding to Mudlet
Fixes #9566. Ambient/background music tracks stop after a single pass,
which affects any game using `Client.Media.Play` with `loops: -1`, MSP,
or `playMusicFile()`.
The immediate source clearing was introduced by #9237 as a
resource-cleanup measure. That cleanup is preserved here — it just runs
one event-loop turn later, and only if the player is still stopped.
#### Other info (issues closed, discussion etc)
Fixes #9566. Regression introduced by #9237 (merged 2026-04-29), so 4.21
and 4.22 are affected; 4.20.1 and earlier are not.
**Verified on Windows against the FFmpeg backend.** The bug reproduces
on unfixed code there, and this change resolves it without regressing
the cleanup that #9237 added.
It is worth being explicit that this could not be confirmed on macOS:
that platform ships `libdarwinmediaplugin`, which emits `EndOfMedia`
*before* `StoppedState` — the reverse ordering — so the loop restarts
before any cleanup runs and the bug cannot occur there at all. Reverting
the fix on macOS produced identical behaviour. The fix is therefore
specific to backends with the FFmpeg ordering, which is where the issue
was reported.
A functional test is included
(`test/functional_tests/TMediaLoopTest.cpp`) covering both halves of the
contract: a looping track must survive the stop/restart cycle, and a
one-shot track must still be cleaned up. It probes the backend before
asserting and **skips** unless that backend can both finish a clip and
emit `StoppedState` before `EndOfMedia`. Without that guard the looping
assertion passes on broken code, which is exactly what happened on
macOS. Expected results:
- FFmpeg backend → runs and asserts
- macOS darwin backend → skips (cannot exhibit the bug)
- Anywhere media stalls under `QT_QPA_PLATFORM=offscreen`, which the
functional suite forces → skips
So it may well skip in CI; that is intentional and preferable to a test
that always passes.
---------
Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-02 13:11:37 -04:00
file . close ( ) ;
return fileName ;
}
// A silent 16-bit mono PCM WAV. Silence is fine: the tests assert on playback state
// transitions, not on what is heard.
static QByteArray wavBytes ( )
{
constexpr int sampleRate = 8000 ;
constexpr int bytesPerSample = 2 ;
const int dataBytes = sampleRate * bytesPerSample * clipMs / 1000 ;
QByteArray wav ;
QDataStream out ( & wav , QIODevice : : WriteOnly ) ;
out . setByteOrder ( QDataStream : : LittleEndian ) ;
out . writeRawData ( " RIFF " , 4 ) ;
out < < static_cast < quint32 > ( 36 + dataBytes ) ;
out . writeRawData ( " WAVE " , 4 ) ;
out . writeRawData ( " fmt " , 4 ) ;
out < < static_cast < quint32 > ( 16 ) ; // PCM header size
out < < static_cast < quint16 > ( 1 ) ; // PCM, uncompressed
out < < static_cast < quint16 > ( 1 ) ; // mono
out < < static_cast < quint32 > ( sampleRate ) ;
out < < static_cast < quint32 > ( sampleRate * bytesPerSample ) ; // byte rate
out < < static_cast < quint16 > ( bytesPerSample ) ; // block align
out < < static_cast < quint16 > ( 8 * bytesPerSample ) ; // bits per sample
out . writeRawData ( " data " , 4 ) ;
out < < static_cast < quint32 > ( dataBytes ) ;
wav . append ( QByteArray ( dataBytes , ' \0 ' ) ) ;
return wav ;
}
// Utility function to manually start a profile like a user would do via the
// GUI
void startProfile ( const QString & hostname , const QString & address , const QString & port )
{
QTimer : : singleShot ( 0 , qApp , [ hostname , address , port ] ( ) {
mudlet : : self ( ) - > startAutoLogin ( { } ) ;
QTest : : qWait ( 100 ms ) ;
QTest : : mouseClick ( mudlet : : self ( ) - > mpConnectionDialog - > new_profile_button , Qt : : LeftButton ) ;
QTest : : qWait ( 100 ms ) ;
QTest : : keyClicks ( QApplication : : focusWidget ( ) , hostname ) ;
QTest : : qWait ( 100 ms ) ;
QTest : : keyClick ( QApplication : : focusWidget ( ) , Qt : : Key_Tab ) ;
QTest : : qWait ( 100 ms ) ;
QTest : : keyClicks ( QApplication : : focusWidget ( ) , address ) ;
QTest : : qWait ( 100 ms ) ;
QTest : : keyClick ( QApplication : : focusWidget ( ) , Qt : : Key_Tab ) ;
QTest : : qWait ( 100 ms ) ;
QTest : : keyClicks ( QApplication : : focusWidget ( ) , port ) ;
QTest : : qWait ( 100 ms ) ;
QTest : : keyClick ( QApplication : : focusWidget ( ) , Qt : : Key_Return ) ;
} ) ;
QSignalSpy spy ( mudlet : : self ( ) , & mudlet : : signal_profileLoaded ) ;
if ( ! spy . wait ( 5 s ) ) {
QFAIL ( " Profile took too long to load. " ) ;
}
}
// Utility function
void deleteProfileDirectory ( const QString & profileName )
{
const QString path = mudlet : : getMudletPath ( enums : : profileHomePath , profileName ) ;
QDir dir ( path ) ;
if ( ! dir . exists ( ) ) {
qInfo ( ) < < " Profile directory does not exist: " < < path ;
return ;
}
dir . removeRecursively ( ) ;
}
} ;
void initializeQRCResourcesForMediaLoop ( )
{
# ifdef INCLUDE_VARIABLE_SPLASH_SCREEN
qInitResources_additional_splash_screens ( ) ;
# endif
# ifdef INCLUDE_FONTS
qInitResources_mudlet_fonts_common ( ) ;
# if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD)
qInitResources_mudlet_fonts_posix ( ) ;
# endif
# endif
qInitResources_mudlet ( ) ;
qInitResources_qm ( ) ;
}
# include "TMediaLoopTest.moc"
QTEST_MAIN ( TMediaLoopTest )