mirror of
https://github.com/Mudlet/Mudlet
synced 2026-08-13 18:26:27 -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>
This commit is contained in:
parent
dd487dc94b
commit
8dd99e4db6
9 changed files with 836 additions and 185 deletions
5
.github/workflows/build-mudlet-pr.yml
vendored
5
.github/workflows/build-mudlet-pr.yml
vendored
|
|
@ -411,6 +411,11 @@ jobs:
|
|||
env:
|
||||
QT_QPA_PLATFORM: offscreen
|
||||
QT_FORCE_STDERR_LOGGING: 1
|
||||
# This runner has Qt's FFmpeg backend and can decode, so TMediaLoopTest must
|
||||
# demonstrate every media behaviour rather than skip any of them. Without a floor
|
||||
# somewhere, a lost codec or a changed default backend would turn the whole file
|
||||
# green-by-skip on every platform and say nothing about it.
|
||||
MUDLET_MEDIA_TESTS_REQUIRE_PLAYBACK: 1
|
||||
|
||||
- name: (macOS) Run C++ tests
|
||||
if: runner.os == 'macOS'
|
||||
|
|
|
|||
5
.github/workflows/build-mudlet-win-pr.yml
vendored
5
.github/workflows/build-mudlet-win-pr.yml
vendored
|
|
@ -140,6 +140,11 @@ jobs:
|
|||
ctest --test-dir $GITHUB_WORKSPACE/build-$MSYSTEM/test --output-on-failure
|
||||
env:
|
||||
QT_FORCE_STDERR_LOGGING: 1
|
||||
# This runner has Qt's FFmpeg backend and can decode, so TMediaLoopTest must
|
||||
# demonstrate every media behaviour rather than skip any of them. Without a floor
|
||||
# somewhere, a lost codec or a changed default backend would turn the whole file
|
||||
# green-by-skip on every platform and say nothing about it.
|
||||
MUDLET_MEDIA_TESTS_REQUIRE_PLAYBACK: 1
|
||||
|
||||
- name: (Windows) Run Lua tests
|
||||
timeout-minutes: 2
|
||||
|
|
|
|||
|
|
@ -2706,7 +2706,6 @@ int TLuaInterpreter::pauseVideos(lua_State* L)
|
|||
int TLuaInterpreter::purgeMediaCache(lua_State* L)
|
||||
{
|
||||
Host& host = getHostFromLua(L);
|
||||
host.mTelnet.purgeMediaCache();
|
||||
lua_pushboolean(L, true);
|
||||
lua_pushboolean(L, host.mTelnet.purgeMediaCache());
|
||||
return 1;
|
||||
}
|
||||
|
|
|
|||
394
src/TMedia.cpp
394
src/TMedia.cpp
|
|
@ -317,6 +317,24 @@ void TMedia::stopMedia(TMediaData& mediaData)
|
|||
continue;
|
||||
}
|
||||
|
||||
// A pooled player between tracks holds no source and has nothing to stop. Criteria this
|
||||
// broad are the common case - a bare stopMusic() or Client.Media.Stop {} matches every
|
||||
// player there is - so without this each idle one would be ended all over again, and
|
||||
// told about with an empty file name and the key and tag of its last track.
|
||||
if (!pPlayer->mediaPlayer() || pPlayer->mediaPlayer()->source().isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Whichever way this track is being ended below, it is not to start again. A looping
|
||||
// or multi-entry track restarts itself from the EndOfMedia handler in
|
||||
// connectMediaPlayer(), which would undo the stop that was just asked for - on a
|
||||
// StoppedState-first backend that signal can still be on its way when the stop
|
||||
// arrives. An emptied playlist is what that handler checks, and play() builds a fresh
|
||||
// one whenever this player is picked up again.
|
||||
if (pPlayer->playlist()) {
|
||||
pPlayer->playlist()->clear();
|
||||
}
|
||||
|
||||
if ((mediaData.mediaFadeAway() == TMediaData::MediaFadeAwayEnabled || mediaData.mediaFadeOut() != TMediaData::MediaFadeNotSet)
|
||||
&& pPlayer->mediaData().mediaEnd() == TMediaData::MediaEndNotSet) {
|
||||
const int finishPosition = pPlayer->mediaData().mediaFinish();
|
||||
|
|
@ -340,7 +358,22 @@ void TMedia::stopMedia(TMediaData& mediaData)
|
|||
}
|
||||
|
||||
// **Stop the player but keep it for reuse**
|
||||
// Only a player that had started reports a change back to StoppedState, and that
|
||||
// signal is what ends the playback and releases the source. One that is still loading
|
||||
// - where a stop issued soon after a play lands on an asynchronous backend - is
|
||||
// already stopped as far as Qt is concerned, so it reports nothing and its source
|
||||
// would be held for good.
|
||||
const bool willReportItsOwnStop = pPlayer->getPlaybackState() != QMediaPlayer::StoppedState;
|
||||
|
||||
pPlayer->mediaPlayer()->stop();
|
||||
|
||||
if (!willReportItsOwnStop) {
|
||||
releaseMediaSourceAfterEvents(pPlayer, pPlayer->mediaData(), PlaybackEnd::Stopped);
|
||||
// Announced at most once per playback, so a handler that stops the media it has
|
||||
// just been told about does not arrive back here for the same track: the source it
|
||||
// reads as live stays set until the deferred release above runs.
|
||||
raiseMediaFinishedEvent(pPlayer, pPlayer->mediaPlayer()->source(), pPlayer->mediaData());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -394,7 +427,12 @@ bool TMedia::purgeMediaCache()
|
|||
}
|
||||
|
||||
stopAllMediaPlayers();
|
||||
mediaDir.removeRecursively();
|
||||
|
||||
if (!mediaDir.removeRecursively()) {
|
||||
qWarning() << qsl("TMedia::purgeMediaCache() WARNING - not able to remove all of directory: %1").arg(mediaPath);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -564,11 +602,53 @@ void TMedia::stopAllMediaPlayers()
|
|||
QList<std::shared_ptr<TMediaPlayer>> mediaPlayerList = findMediaPlayersByCriteria(mediaData);
|
||||
|
||||
for (const auto& pPlayer : std::as_const(mediaPlayerList)) {
|
||||
if (!pPlayer) {
|
||||
continue;
|
||||
if (!pPlayer || !pPlayer->mediaPlayer() || pPlayer->mediaPlayer()->source().isEmpty()) {
|
||||
continue; // A pooled player between tracks has nothing playing to stop
|
||||
}
|
||||
|
||||
// Everything the ending is described by has to be read before the source goes, because
|
||||
// releasing is what makes it unreadable.
|
||||
const TMediaData endedData = pPlayer->mediaData();
|
||||
const QUrl endedUrl = pPlayer->mediaPlayer()->source();
|
||||
const bool hadVideoOutput = pPlayer->mediaPlayer()->videoOutput() != nullptr;
|
||||
const quint64 claimedAt = pPlayer->claimGeneration();
|
||||
|
||||
// No loop is to survive this: the EndOfMedia handler restarts one from the playlist,
|
||||
// and a StoppedState-first backend can still have that signal on its way.
|
||||
if (pPlayer->playlist()) {
|
||||
pPlayer->playlist()->clear();
|
||||
}
|
||||
|
||||
pPlayer->mediaPlayer()->stop();
|
||||
|
||||
// stop() can deliver StoppedState synchronously, whose handler raises sysMediaFinished
|
||||
// and so lets a script hand this player straight to another track. The release below is
|
||||
// direct - it carries no generation of its own for releaseMediaSourceAfterEvents()'
|
||||
// checks to catch - so this is the one thing standing between that new track and having
|
||||
// its source cleared out from under it.
|
||||
if (pPlayer->claimGeneration() != claimedAt) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Released here rather than left to releaseMediaSourceAfterEvents(): this is a
|
||||
// teardown, so there is no loop left to restart and no reason to wait a turn, and a
|
||||
// caller may need the files free straight away - purgeMediaCache() deletes them. The
|
||||
// empty source left behind is also what tells any release already scheduled for this
|
||||
// player to stay quiet when its turn comes, so nothing is said twice.
|
||||
pPlayer->releaseSource();
|
||||
|
||||
if (endedData.mediaWidget() == TMediaData::MediaWidgetLabel && endedData.mediaClose() == TMediaData::MediaCloseEnabled && hadVideoOutput) {
|
||||
emit signal_hideVideoOutput(pPlayer.get());
|
||||
}
|
||||
|
||||
// Announced from here because releasing synchronously means no deferred turn will do
|
||||
// it: on a backend that reports StoppedState asynchronously nothing else ever would,
|
||||
// and a script waiting on sysMediaFinished would sit through the teardown none the
|
||||
// wiser. Skipped when stop() above already announced it - see endAnnounced().
|
||||
raiseMediaFinishedEvent(pPlayer, endedUrl, endedData);
|
||||
|
||||
//: This word is part of a sentence like "Music stops" when the music is about to stop.
|
||||
printClosedCaption(endedData, tr("stops"));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -588,23 +668,25 @@ int TMedia::playersHoldingSource() const
|
|||
+ countHeld(mAPIVideoList);
|
||||
}
|
||||
|
||||
// The cleanup that releases a stopped player's source runs one event-loop turn
|
||||
// after the stop (see handlePlayerPlaybackStateChanged), so a player reused for
|
||||
// a new play request can still hold the file it just finished. Replaying that
|
||||
// same file leaves QMediaPlayer with its media already loaded, so play() starts
|
||||
// it synchronously, raising sysMediaStarted re-entrantly inside the script call
|
||||
// that started it. Finish the deferred cleanup here instead, so every fresh
|
||||
// play request loads its media asynchronously, as it did when the cleanup ran
|
||||
// at stop time.
|
||||
void TMedia::releaseStoppedSource(const std::shared_ptr<TMediaPlayer>& player)
|
||||
int TMedia::playersInPlayingState() const
|
||||
{
|
||||
if (!player || !player->mediaPlayer()) {
|
||||
return;
|
||||
}
|
||||
const auto countPlaying = [](const QList<std::shared_ptr<TMediaPlayer>>& list) {
|
||||
int playing = 0;
|
||||
for (const auto& player : list) {
|
||||
if (player && player->getPlaybackState() == QMediaPlayer::PlayingState) {
|
||||
++playing;
|
||||
}
|
||||
}
|
||||
return playing;
|
||||
};
|
||||
|
||||
if (player->getPlaybackState() == QMediaPlayer::StoppedState && !player->mediaPlayer()->source().isEmpty()) {
|
||||
player->mediaPlayer()->setSource(QUrl());
|
||||
}
|
||||
return countPlaying(mMSPSoundList) + countPlaying(mMSPMusicList) + countPlaying(mGMCPSoundList) + countPlaying(mGMCPMusicList) + countPlaying(mGMCPVideoList) + countPlaying(mAPISoundList)
|
||||
+ countPlaying(mAPIMusicList) + countPlaying(mAPIVideoList);
|
||||
}
|
||||
|
||||
int TMedia::mediaPlayerCount() const
|
||||
{
|
||||
return mMSPSoundList.size() + mMSPMusicList.size() + mGMCPSoundList.size() + mGMCPMusicList.size() + mGMCPVideoList.size() + mAPISoundList.size() + mAPIMusicList.size() + mAPIVideoList.size();
|
||||
}
|
||||
|
||||
void TMedia::setMediaPlayersMuted(const TMediaData::MediaProtocol mediaProtocol, const bool state)
|
||||
|
|
@ -1086,7 +1168,7 @@ QString TMedia::setupMediaAbsolutePathFileName(TMediaData& mediaData)
|
|||
|
||||
void TMedia::connectMediaPlayer(std::shared_ptr<TMediaPlayer>& player)
|
||||
{
|
||||
if (!player) {
|
||||
if (!player || !player->mediaPlayer()) {
|
||||
qWarning() << qsl("TMedia::connectMediaPlayer() WARNING - Attempted to connect a null TMediaPlayer.");
|
||||
return;
|
||||
}
|
||||
|
|
@ -1112,20 +1194,80 @@ void TMedia::connectMediaPlayer(std::shared_ptr<TMediaPlayer>& player)
|
|||
QUrl nextMedia = lockedPlayer->playlist()->next();
|
||||
|
||||
if (!nextMedia.isEmpty()) {
|
||||
lockedPlayer->noteContinued();
|
||||
lockedPlayer->mediaPlayer()->setSource(nextMedia);
|
||||
lockedPlayer->mediaPlayer()->play();
|
||||
lockedPlayer->continuePlaying(nextMedia);
|
||||
} else if (lockedPlayer->playlist()->playbackMode() == TMediaPlaylist::Loop) {
|
||||
lockedPlayer->noteContinued();
|
||||
lockedPlayer->playlist()->setCurrentIndex(0);
|
||||
lockedPlayer->mediaPlayer()->setSource(lockedPlayer->playlist()->currentMedia());
|
||||
lockedPlayer->mediaPlayer()->play();
|
||||
lockedPlayer->continuePlaying(lockedPlayer->playlist()->currentMedia());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Error connection
|
||||
disconnect(player->mediaPlayer(), &QMediaPlayer::errorOccurred, nullptr, nullptr);
|
||||
connect(player->mediaPlayer(), &QMediaPlayer::errorOccurred, this, [this, weakPlayer](QMediaPlayer::Error error, const QString& errorString) {
|
||||
const auto lockedPlayer = weakPlayer.lock();
|
||||
|
||||
if (!lockedPlayer || !lockedPlayer->mediaPlayer() || error == QMediaPlayer::NoError) {
|
||||
return;
|
||||
}
|
||||
|
||||
qWarning().noquote() << qsl("TMedia::connectMediaPlayer() WARNING - media player error %1 on \"%2\": %3")
|
||||
.arg(QString::number(static_cast<int>(error)), lockedPlayer->mediaPlayer()->source().toString(), errorString);
|
||||
|
||||
if (mudlet::smDebugMode && mpHost && mpHost->mpConsole) {
|
||||
//: %1 is the media backend's own description of what went wrong, e.g. "Failed to load media".
|
||||
mpHost->mpConsole->printSystemMessage(qsl("%1\n").arg(tr("Media error: %1").arg(errorString)));
|
||||
}
|
||||
|
||||
// Only a failure nothing else will report is ended from here. A track that was playing
|
||||
// reports StoppedState when the error takes it down, and the playback state handler
|
||||
// ends it from there. That leaves two cases: a player already stopped, which is where a
|
||||
// load failure lands because claimSource() leaves it stopped and there is no state to
|
||||
// change from; and Qt's darwin backend, which reports PlayingState for media it has
|
||||
// just failed to load and then never moves off it. InvalidMedia catches that second
|
||||
// case and only that one - it cannot be asked to carry the first, because Qt's FFmpeg
|
||||
// backend raises this signal before it sets the status.
|
||||
if (lockedPlayer->mediaPlayer()->mediaStatus() != QMediaPlayer::InvalidMedia && lockedPlayer->getPlaybackState() != QMediaPlayer::StoppedState) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Nothing else will end it: a source set on a player that was already stopped - which
|
||||
// is what every claimSource() on a new or finished player does, and what a loop restart
|
||||
// or playlist advance does from the EndOfMedia handler - has no state to change from.
|
||||
// Left alone the track falls silent still holding a source nothing will ever release,
|
||||
// and a script waiting on sysMediaFinished to start the next one waits forever.
|
||||
//
|
||||
// Ended a turn from now rather than here, because setSource() can deliver this error
|
||||
// synchronously from inside claimSource(): sysMediaFinished would then reach a script
|
||||
// in the middle of the playMusic() call that asked for the track, and a handler that
|
||||
// responds by playing the same undecodable file again would recurse until the stack
|
||||
// gave out. The claim generation says whether this failure is still anyone's to report
|
||||
// by the time the turn comes: a track that took the player over in between owns it now.
|
||||
const quint64 claimedAt = lockedPlayer->claimGeneration();
|
||||
|
||||
QTimer::singleShot(0, this, [this, weakPlayer, claimedAt] {
|
||||
const auto endingPlayer = weakPlayer.lock();
|
||||
|
||||
if (!endingPlayer || !endingPlayer->mediaPlayer() || endingPlayer->claimGeneration() != claimedAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The release armed below ignores playback state by design, since darwin claims to
|
||||
// be playing media it has just failed to load. That makes this the only place an
|
||||
// error the backend recovered from can be told apart from one it did not: a turn
|
||||
// on, media it has condemned says so with InvalidMedia, and media that is playing
|
||||
// without having been condemned is fine after all and must be left alone.
|
||||
if (endingPlayer->mediaPlayer()->mediaStatus() != QMediaPlayer::InvalidMedia && endingPlayer->getPlaybackState() == QMediaPlayer::PlayingState) {
|
||||
return;
|
||||
}
|
||||
|
||||
releaseMediaSourceAfterEvents(endingPlayer, endingPlayer->mediaData(), PlaybackEnd::Failed);
|
||||
raiseMediaFinishedEvent(endingPlayer, endingPlayer->mediaPlayer()->source(), endingPlayer->mediaData());
|
||||
});
|
||||
});
|
||||
|
||||
// Playback state changed connection
|
||||
disconnect(player->mediaPlayer(), &QMediaPlayer::playbackStateChanged, nullptr, nullptr);
|
||||
connect(player->mediaPlayer(), &QMediaPlayer::playbackStateChanged, this, [this, weakPlayer](QMediaPlayerPlaybackState playbackState) {
|
||||
|
|
@ -1381,6 +1523,128 @@ void TMedia::getMediaPlayerCounts(int& soundPlayers, int& musicPlayers, int& sto
|
|||
}
|
||||
#endif // MUDLET_MEMORY_TRACKING
|
||||
|
||||
// Tells scripts a playback is over. Raised for a failed load as well as for a stop, because a
|
||||
// script that starts its next track from sysMediaFinished otherwise waits forever on the first
|
||||
// file the backend cannot decode.
|
||||
void TMedia::raiseMediaFinishedEvent(const std::shared_ptr<TMediaPlayer>& player, const QUrl& endedUrl, const TMediaData& endedData)
|
||||
{
|
||||
if (!mpHost || !player) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (endedUrl.isEmpty()) {
|
||||
// A pooled player between tracks. There is no playback to report, and the event would
|
||||
// carry an empty file name and path with the key and tag of whatever it last played.
|
||||
return;
|
||||
}
|
||||
|
||||
if (player->endAnnounced()) {
|
||||
// Already reported by whichever of the stop, the error and the StoppedState got here
|
||||
// first - see TMediaPlayer::endAnnounced().
|
||||
return;
|
||||
}
|
||||
|
||||
// Set before the handlers run, not after: raiseEvent() dispatches synchronously, and a
|
||||
// handler that stops this player would otherwise arrive back here and announce again.
|
||||
player->noteEndAnnounced();
|
||||
|
||||
TEvent mediaFinished{};
|
||||
mediaFinished.mArgumentList.append(qsl("sysMediaFinished"));
|
||||
|
||||
mediaFinished.mArgumentList.append(endedUrl.fileName());
|
||||
mediaFinished.mArgumentList.append(endedUrl.path());
|
||||
mediaFinished.mArgumentList.append(mediaTypeToString(endedData.mediaType()));
|
||||
mediaFinished.mArgumentList.append(endedData.mediaKey());
|
||||
mediaFinished.mArgumentList.append(endedData.mediaTag());
|
||||
mediaFinished.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
|
||||
mediaFinished.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
|
||||
mediaFinished.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
|
||||
mediaFinished.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
|
||||
mediaFinished.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
|
||||
mediaFinished.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
|
||||
|
||||
mpHost->raiseEvent(mediaFinished);
|
||||
}
|
||||
|
||||
// Ends a playback: releases the media source and prints the closing caption, one event-loop
|
||||
// turn from now. Deferred so a StoppedState-first backend can still emit the EndOfMedia that
|
||||
// restarts a loop - clearing the source immediately destroys the playback engine and that
|
||||
// signal never arrives (#9566). See TMediaPlayer for the generation counters this compares.
|
||||
//
|
||||
// endedBy decides whether the player's own state gets a say in the deferred turn. A stop needs
|
||||
// it: on an EndOfMedia-first backend the restart happened before the snapshot, so the
|
||||
// continuation counter cannot see it and a player still reporting PlayingState is the only sign
|
||||
// the track carried on. A failure must not have it, because a backend can report PlayingState
|
||||
// for media it has just failed to load (Qt 6.9's darwin backend does), and believing that would
|
||||
// leave the dead source held forever - no state change follows to schedule another release.
|
||||
void TMedia::releaseMediaSourceAfterEvents(const std::shared_ptr<TMediaPlayer>& player, const TMediaData& endedData, const PlaybackEnd endedBy)
|
||||
{
|
||||
const bool playbackStateDecides = (endedBy == PlaybackEnd::Stopped);
|
||||
|
||||
if (!player->mediaPlayer() || player->mediaPlayer()->source().isEmpty()) {
|
||||
// Nothing left to end, so no caption for it either
|
||||
qDebug() << "TMedia::releaseMediaSourceAfterEvents() - asked to end a playback that is already holding no source; nothing to do.";
|
||||
return;
|
||||
}
|
||||
|
||||
const std::weak_ptr<TMediaPlayer> weakPlayer = player;
|
||||
const quint64 claimedAt = player->claimGeneration();
|
||||
const quint64 continuedAt = player->continuationGeneration();
|
||||
|
||||
QTimer::singleShot(0, this, [this, weakPlayer, endedData, claimedAt, continuedAt, playbackStateDecides] {
|
||||
const auto lockedPlayer = weakPlayer.lock();
|
||||
const bool stillOurs = lockedPlayer && lockedPlayer->claimGeneration() == claimedAt;
|
||||
// Two ways the same playback can have carried on during the deferred turn. On a
|
||||
// StoppedState-first backend the loop restarts from the EndOfMedia handler after the
|
||||
// snapshot above, so the counter is what sees it; on an EndOfMedia-first backend the
|
||||
// restart already happened before the snapshot, so the counter cannot see it and the
|
||||
// player still reporting PlayingState is.
|
||||
const bool sameMediaContinues =
|
||||
lockedPlayer && (lockedPlayer->continuationGeneration() != continuedAt || (playbackStateDecides && stillOurs && lockedPlayer->getPlaybackState() == QMediaPlayer::PlayingState));
|
||||
|
||||
if (sameMediaContinues) {
|
||||
// No caption either: nothing ended, so "stops" between the passes of a looping
|
||||
// track would be wrong. Logged because this is the one outcome that keeps a source
|
||||
// on purpose, which makes it the first thing to rule out when one is held too long.
|
||||
qDebug() << "TMedia::releaseMediaSourceAfterEvents() - the same playback carried on into another pass; keeping its source.";
|
||||
return;
|
||||
}
|
||||
|
||||
// Releasing bumps no generation, so any path that clears the source itself leaves a
|
||||
// pending turn still looking entitled to end this playback - an error and the stop
|
||||
// that follows it, stopAllMediaPlayers(), the setupVideo() failure in play(). Each of
|
||||
// those announces its own ending, so this one has nothing left to do or to say.
|
||||
if (stillOurs && lockedPlayer->mediaPlayer() && lockedPlayer->mediaPlayer()->source().isEmpty()) {
|
||||
qDebug() << "TMedia::releaseMediaSourceAfterEvents() - this playback was already ended by whoever released the source; nothing left to do.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!lockedPlayer) {
|
||||
qDebug() << "TMedia::releaseMediaSourceAfterEvents() - player was destroyed before its deferred release ran; its destructor released the source.";
|
||||
} else if (!stillOurs) {
|
||||
// A claimed player is already loading the source of the track that took it over,
|
||||
// which on an asynchronous backend still reads as stopped.
|
||||
qDebug() << "TMedia::releaseMediaSourceAfterEvents() - another track claimed this player before its deferred release ran; keeping the new source.";
|
||||
} else if (!lockedPlayer->mediaPlayer()) {
|
||||
qWarning() << "TMedia::releaseMediaSourceAfterEvents() WARNING - mediaPlayer() is null, cannot release the media source.";
|
||||
} else if (playbackStateDecides && lockedPlayer->getPlaybackState() != QMediaPlayer::StoppedState) {
|
||||
qDebug() << "TMedia::releaseMediaSourceAfterEvents() - player is no longer stopped, keeping its source.";
|
||||
} else {
|
||||
qDebug() << "TMedia::releaseMediaSourceAfterEvents() - releasing the media source of the playback that ended.";
|
||||
lockedPlayer->releaseSource();
|
||||
|
||||
if (endedData.mediaWidget() == TMediaData::MediaWidgetLabel && endedData.mediaClose() == TMediaData::MediaCloseEnabled && lockedPlayer->mediaPlayer()->videoOutput() != nullptr) {
|
||||
emit signal_hideVideoOutput(lockedPlayer.get());
|
||||
}
|
||||
}
|
||||
|
||||
// Printed on every path that got past the continuation check: the track this release
|
||||
// was scheduled for is over regardless of what has become of the player since.
|
||||
//: This word is part of a sentence like "Music stops" when the music is about to stop.
|
||||
printClosedCaption(endedData, tr("stops"));
|
||||
});
|
||||
}
|
||||
|
||||
void TMedia::handlePlayerPlaybackStateChanged(QMediaPlayerPlaybackState playbackState, const std::shared_ptr<TMediaPlayer>& player)
|
||||
{
|
||||
if (!player) {
|
||||
|
|
@ -1388,60 +1652,20 @@ void TMedia::handlePlayerPlaybackStateChanged(QMediaPlayerPlaybackState playback
|
|||
}
|
||||
|
||||
if (playbackState == QMediaPlayer::StoppedState) {
|
||||
// Captured before the event below, because a sysMediaFinished handler runs
|
||||
// synchronously and may hand this player to the next track.
|
||||
const std::weak_ptr<TMediaPlayer> weakPlayer = player;
|
||||
const TMediaData stoppedData = player->mediaData();
|
||||
const quint64 claimGeneration = player->claimGeneration();
|
||||
const quint64 continuationGeneration = player->continuationGeneration();
|
||||
|
||||
TEvent mediaFinished{};
|
||||
mediaFinished.mArgumentList.append(qsl("sysMediaFinished"));
|
||||
|
||||
const QUrl mediaUrl = player->mediaPlayer()->source();
|
||||
mediaFinished.mArgumentList.append(mediaUrl.fileName());
|
||||
mediaFinished.mArgumentList.append(mediaUrl.path());
|
||||
mediaFinished.mArgumentList.append(mediaTypeToString(player->mediaData().mediaType()));
|
||||
mediaFinished.mArgumentList.append(player->mediaData().mediaKey());
|
||||
mediaFinished.mArgumentList.append(player->mediaData().mediaTag());
|
||||
mediaFinished.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
|
||||
mediaFinished.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
|
||||
mediaFinished.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
|
||||
mediaFinished.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
|
||||
mediaFinished.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
|
||||
mediaFinished.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
|
||||
|
||||
if (mpHost) {
|
||||
mpHost->raiseEvent(mediaFinished);
|
||||
if (!player->mediaPlayer() || player->mediaPlayer()->source().isEmpty()) {
|
||||
// Whoever released the source already ended this playback and raised its event. A
|
||||
// second one from here would carry an empty file name and path, because the URL
|
||||
// they describe is exactly what was just cleared.
|
||||
qDebug() << "TMedia::handlePlayerPlaybackStateChanged() - stopped a player that is already holding no source; its playback was ended elsewhere.";
|
||||
return;
|
||||
}
|
||||
|
||||
// Deferred so the backend can still emit EndOfMedia, which is what restarts a loop:
|
||||
// clearing the source here destroys the playback engine and that signal never arrives.
|
||||
QTimer::singleShot(0, this, [this, weakPlayer, stoppedData, claimGeneration, continuationGeneration] {
|
||||
const auto lockedPlayer = weakPlayer.lock();
|
||||
const bool stillOurs = lockedPlayer && lockedPlayer->claimGeneration() == claimGeneration;
|
||||
// Backends that emit EndOfMedia before StoppedState have already restarted the
|
||||
// loop by now, so a player still playing its own track has not stopped either.
|
||||
const bool sameMediaContinues =
|
||||
lockedPlayer && (lockedPlayer->continuationGeneration() != continuationGeneration || (stillOurs && lockedPlayer->getPlaybackState() == QMediaPlayer::PlayingState));
|
||||
// Scheduled before the event below, because a sysMediaFinished handler runs
|
||||
// synchronously and may hand this player to the next track - which would change both
|
||||
// the media data and the generations the release has to be judged against.
|
||||
releaseMediaSourceAfterEvents(player, player->mediaData(), PlaybackEnd::Stopped);
|
||||
raiseMediaFinishedEvent(player, player->mediaPlayer()->source(), player->mediaData());
|
||||
|
||||
if (sameMediaContinues) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only release a player nothing else has taken over: a claimed one is already
|
||||
// loading its new source, which on an asynchronous backend still reads as stopped.
|
||||
if (stillOurs && lockedPlayer->mediaPlayer() && lockedPlayer->getPlaybackState() == QMediaPlayer::StoppedState) {
|
||||
lockedPlayer->mediaPlayer()->setSource(QUrl());
|
||||
|
||||
if (stoppedData.mediaWidget() == TMediaData::MediaWidgetLabel && stoppedData.mediaClose() == TMediaData::MediaCloseEnabled && lockedPlayer->mediaPlayer()->videoOutput() != nullptr) {
|
||||
emit signal_hideVideoOutput(lockedPlayer.get());
|
||||
}
|
||||
}
|
||||
|
||||
//: This word is part of a sentence like "Music stops" when the music is about to stop.
|
||||
printClosedCaption(stoppedData, tr("stops"));
|
||||
});
|
||||
return;
|
||||
} else if (playbackState == QMediaPlayer::PlayingState && player->mediaData().mediaVolume() != TMediaData::MediaVolumePreload) { // NOLINT(readability-else-after-return)
|
||||
TEvent mediaStarted{};
|
||||
|
|
@ -1677,9 +1901,7 @@ void TMedia::play(TMediaData& mediaData)
|
|||
}
|
||||
|
||||
const QUrl mediaSource = mediaData.mediaInput() == TMediaData::MediaInputFile ? QUrl::fromLocalFile(absolutePathFileName) : QUrl(absolutePathFileName);
|
||||
releaseStoppedSource(pPlayer);
|
||||
pPlayer->noteClaimed();
|
||||
pPlayer->mediaPlayer()->setSource(mediaSource);
|
||||
pPlayer->claimSource(mediaSource);
|
||||
} else {
|
||||
if (mediaData.mediaLoops() == TMediaData::MediaLoopsRepeat) { // Repeat indefinitely
|
||||
playlist->setPlaybackMode(TMediaPlaylist::Loop);
|
||||
|
|
@ -1745,9 +1967,7 @@ void TMedia::play(TMediaData& mediaData)
|
|||
|
||||
playlist->setCurrentIndex(0);
|
||||
pPlayer->setPlaylist(playlist);
|
||||
releaseStoppedSource(pPlayer);
|
||||
pPlayer->noteClaimed();
|
||||
pPlayer->mediaPlayer()->setSource(playlist->currentMedia());
|
||||
pPlayer->claimSource(playlist->currentMedia());
|
||||
}
|
||||
|
||||
// Set volume and start position
|
||||
|
|
@ -1772,6 +1992,16 @@ void TMedia::play(TMediaData& mediaData)
|
|||
|
||||
// Handle video setup if applicable
|
||||
if (mediaData.mediaType() == TMediaData::MediaTypeVideo && !setupVideo(pPlayer)) {
|
||||
// Claiming the player disarmed any release still pending on it, so drop the source it
|
||||
// is now never going to play rather than leave it held indefinitely.
|
||||
pPlayer->releaseSource();
|
||||
|
||||
// Same guards as the deferred release: a reused player can still be showing the widget
|
||||
// of an earlier clip, and hiding that is only wanted when this request asked for it.
|
||||
if (mediaData.mediaWidget() == TMediaData::MediaWidgetLabel && mediaData.mediaClose() == TMediaData::MediaCloseEnabled && pPlayer->mediaPlayer()->videoOutput() != nullptr) {
|
||||
emit signal_hideVideoOutput(pPlayer.get());
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
82
src/TMedia.h
82
src/TMedia.h
|
|
@ -34,6 +34,7 @@
|
|||
#include <memory>
|
||||
#include <QAudioOutput>
|
||||
#include <QMediaPlayer>
|
||||
#include <QUrl>
|
||||
|
||||
class QJsonObject;
|
||||
|
||||
|
|
@ -63,15 +64,63 @@ public:
|
|||
TMediaData mediaData() const { return mMediaData; }
|
||||
void setMediaData(TMediaData& mediaData) { mMediaData = mediaData; }
|
||||
|
||||
// A stop is acted on one event-loop turn late, by which time a stopped player is
|
||||
// indistinguishable from one asynchronously loading a source set since. These
|
||||
// record what happened in between: a claim is this player being given a new source
|
||||
// to play, a continuation is its own playlist advancing or looping.
|
||||
// TMedia::releaseMediaSourceAfterEvents() ends a playback one event-loop turn late, by which
|
||||
// time a stopped player is indistinguishable from one asynchronously loading a source set
|
||||
// since. These two counters record what happened in between: a claim is this player being
|
||||
// given a new track to play, a continuation is its own playlist advancing or looping.
|
||||
// Outside this class, install a source only through claimSource() or continuePlaying() -
|
||||
// never through mediaPlayer()->setSource() directly, since a missed bump lets that pending
|
||||
// release clear the new source again. As of Qt 6.9 that reproduces only on backends that
|
||||
// load asynchronously, so it will not show up on a macOS-only test run.
|
||||
void claimSource(const QUrl& media)
|
||||
{
|
||||
// Bumped before the source is touched because setSource() can raise errorOccurred
|
||||
// synchronously, and that handler snapshots these counters to arm its own release.
|
||||
++mClaimGeneration;
|
||||
mEndAnnounced = false;
|
||||
if (mMediaPlayer) {
|
||||
// A stopped player still holding anything has that media loaded, so handing it a
|
||||
// source now starts playback synchronously and raises sysMediaStarted inside the
|
||||
// script call that asked for it. Unloading first restores the usual asynchronous
|
||||
// start. The reported symptom was replaying the same file (#9611).
|
||||
if (mMediaPlayer->playbackState() == QMediaPlayer::StoppedState && !mMediaPlayer->source().isEmpty()) {
|
||||
releaseSource();
|
||||
}
|
||||
mMediaPlayer->setSource(media);
|
||||
}
|
||||
}
|
||||
void continuePlaying(const QUrl& media)
|
||||
{
|
||||
++mContinuationGeneration;
|
||||
mEndAnnounced = false;
|
||||
if (mMediaPlayer) {
|
||||
mMediaPlayer->setSource(media);
|
||||
mMediaPlayer->play();
|
||||
}
|
||||
}
|
||||
// No bump: an empty source cannot be mistaken for a track that needs protecting from a
|
||||
// pending release. A release already scheduled therefore still fires, and recognises that
|
||||
// it has nothing left to do by the source being empty - see releaseMediaSourceAfterEvents().
|
||||
void releaseSource()
|
||||
{
|
||||
if (mMediaPlayer) {
|
||||
mMediaPlayer->setSource(QUrl());
|
||||
}
|
||||
}
|
||||
quint64 claimGeneration() const { return mClaimGeneration; }
|
||||
void noteClaimed() { ++mClaimGeneration; }
|
||||
quint64 continuationGeneration() const { return mContinuationGeneration; }
|
||||
void noteContinued() { ++mContinuationGeneration; }
|
||||
|
||||
// One ended playback can be reported from three places - a stop, a load error and the
|
||||
// StoppedState that follows either - and the source stays set until the deferred release
|
||||
// runs, so each of them still finds a playback that looks live. Only the first may tell
|
||||
// scripts about it: a second sysMediaFinished for the same track is at best a duplicate,
|
||||
// and at worst unbounded recursion when the handler stops the media it was told about.
|
||||
// Cleared by the two ways this player is given something new to play, above.
|
||||
bool endAnnounced() const { return mEndAnnounced; }
|
||||
void noteEndAnnounced() { mEndAnnounced = true; }
|
||||
|
||||
// Read-only uses and playback control are fine; do not setSource() on it, for the reason
|
||||
// given above claimSource().
|
||||
QMediaPlayer* mediaPlayer() const { return mMediaPlayer.get(); }
|
||||
bool isInitialized() const { return initialized; }
|
||||
QMediaPlayer::PlaybackState getPlaybackState() const
|
||||
|
|
@ -129,6 +178,7 @@ private:
|
|||
bool initialized = false;
|
||||
quint64 mClaimGeneration = 0;
|
||||
quint64 mContinuationGeneration = 0;
|
||||
bool mEndAnnounced = false;
|
||||
};
|
||||
|
||||
class TMedia : public QObject
|
||||
|
|
@ -161,9 +211,17 @@ public:
|
|||
void printClosedCaption(const TMediaData& mediaData, const QString& action) const;
|
||||
void stopAllMediaPlayers();
|
||||
|
||||
// Number of players still holding a media source. Releasing that source is the only
|
||||
// observable effect of the deferred stop cleanup, so tests need a way to see it.
|
||||
// Read-only diagnostics for the media tests. A deferred release is otherwise hard to
|
||||
// observe: playingMedia() has already dropped the player, the closed caption needs captions
|
||||
// enabled and signal_hideVideoOutput needs a video widget.
|
||||
int playersHoldingSource() const;
|
||||
// Players that have actually started. playingMedia() deliberately counts one that is still
|
||||
// loading as playing, which is not enough for a test that needs playback truly under way.
|
||||
int playersInPlayingState() const;
|
||||
// Players registered in the protocol lists, so a reuse test can tell a claimed player from
|
||||
// a second one allocated alongside it. A player play() abandons before it finishes is never
|
||||
// registered and so is never counted.
|
||||
int mediaPlayerCount() const;
|
||||
|
||||
// Returns true if mediaFileName would resolve to a location outside mediaRoot, either
|
||||
// lexically (e.g. via "../" traversal) or through a symlink component that already exists
|
||||
|
|
@ -183,7 +241,6 @@ private:
|
|||
bool isMediaMatch(const std::shared_ptr<TMediaPlayer>& player, const TMediaData& mediaData);
|
||||
bool resume(TMediaData mediaData);
|
||||
void setMediaPlayersMuted(const TMediaData::MediaProtocol mediaProtocol, const bool state);
|
||||
static void releaseStoppedSource(const std::shared_ptr<TMediaPlayer>& player);
|
||||
void transitionNonRelativeFile(TMediaData& mediaData);
|
||||
QString getStreamUrl(const TMediaData& mediaData);
|
||||
QUrl parseUrl(TMediaData& mediaData);
|
||||
|
|
@ -205,6 +262,13 @@ private:
|
|||
std::shared_ptr<TMediaPlayer> matchMediaPlayer(TMediaData& mediaData);
|
||||
bool doesMediaHavePriorityToPlay(TMediaData& mediaData, const QString& absolutePathFileName);
|
||||
void matchMediaKeyAndStopMediaVariants(TMediaData& mediaData, const QString& absolutePathFileName);
|
||||
// Why a playback ended, which decides whether the player's own state is worth consulting
|
||||
// when the deferred release comes around. See releaseMediaSourceAfterEvents().
|
||||
enum class PlaybackEnd { Stopped, Failed };
|
||||
// endedUrl and endedData are passed in rather than read off the player, so a caller that has
|
||||
// already released the source can still say what it was that ended.
|
||||
void raiseMediaFinishedEvent(const std::shared_ptr<TMediaPlayer>& player, const QUrl& endedUrl, const TMediaData& endedData);
|
||||
void releaseMediaSourceAfterEvents(const std::shared_ptr<TMediaPlayer>& player, const TMediaData& endedData, const PlaybackEnd endedBy);
|
||||
void handlePlayerPlaybackStateChanged(QMediaPlayerPlaybackState playbackState, const std::shared_ptr<TMediaPlayer>& player);
|
||||
bool setupVideo(const std::shared_ptr<TMediaPlayer>& player);
|
||||
static QString mediaTypeToString(int mediaType);
|
||||
|
|
|
|||
|
|
@ -914,14 +914,19 @@ void dlgProfilePreferences::initWithHost(Host* pHost)
|
|||
checkBox_discordServerAccessToPartyInfo->setChecked(!(discordFlags & Host::DiscordSetPartyInfo));
|
||||
checkBox_discordServerAccessToTimerInfo->setChecked(!(discordFlags & Host::DiscordSetTimeInfo));
|
||||
lineEdit_discordUserName->setText(pHost->mRequiredDiscordUserName);
|
||||
lineEdit_discordUserName->setToolTip(utils::richText(tr("Mudlet will only show Rich Presence information while you use this Discord username (useful if you have multiple Discord accounts). Leave empty to show it for any Discord account you log in to. This must be the unique Discord username that uses a restricted lowercase ASCII character set and not any \"Nickname\" that you may have set for a particular Server.")));
|
||||
lineEdit_discordUserName->setAccessibleDescription(tr("Mudlet will only show Rich Presence information while you use this Discord username (useful if you have multiple Discord accounts). Leave empty to show it for any Discord account you log in to. This must be the unique Discord username that uses a restricted lowercase ASCII character set and not any \"Nickname\" that you may have set for a particular Server."));
|
||||
lineEdit_discordUserName->setToolTip(utils::richText(tr("Mudlet will only show Rich Presence information while you use this Discord username (useful if you have multiple Discord accounts). "
|
||||
"Leave empty to show it for any Discord account you log in to. This must be the unique Discord username that uses a restricted "
|
||||
"lowercase ASCII character set and not any \"Nickname\" that you may have set for a particular Server.")));
|
||||
lineEdit_discordUserName->setAccessibleDescription(tr("Mudlet will only show Rich Presence information while you use this Discord username (useful if you have multiple Discord accounts). "
|
||||
"Leave empty to show it for any Discord account you log in to. This must be the unique Discord username that uses a restricted lowercase "
|
||||
"ASCII character set and not any \"Nickname\" that you may have set for a particular Server."));
|
||||
|
||||
const QString currentDiscordUser = Discord::getLoggedInUserName();
|
||||
if (!currentDiscordUser.isEmpty()) {
|
||||
//: Shows which Discord account is logged in:
|
||||
label_data_discordCurrentUser->setText(currentDiscordUser);
|
||||
label_data_discordCurrentUser->setToolTip(utils::richText(tr("This is the unique username using a restricted character set for the Discord account, and not necessarily the nickname that you might have set for a particular Server.")));
|
||||
label_data_discordCurrentUser->setToolTip(utils::richText(
|
||||
tr("This is the unique username using a restricted character set for the Discord account, and not necessarily the nickname that you might have set for a particular Server.")));
|
||||
} else {
|
||||
label_data_discordCurrentUser->setText(tr("(Not connected)"));
|
||||
//: Tooltip shown when Discord Rich Presence cannot detect a logged-in user
|
||||
|
|
@ -2095,7 +2100,14 @@ void dlgProfilePreferences::slot_purgeMediaCache()
|
|||
return;
|
||||
}
|
||||
|
||||
pHost->mpMedia->purgeMediaCache();
|
||||
if (!pHost->mpMedia->purgeMediaCache()) {
|
||||
//: Shown after the "Clear stored media" button in preferences fails to empty the profile's media directory.
|
||||
pHost->postMessage(tr("[ WARN ] - Could not clear all of the stored media; some files may still be in use."));
|
||||
return;
|
||||
}
|
||||
|
||||
//: Shown after the "Clear stored media" button in preferences empties the profile's media directory.
|
||||
pHost->postMessage(tr("[ OK ] - The stored media files for this profile have been cleared."));
|
||||
}
|
||||
|
||||
void dlgProfilePreferences::slot_resetColors()
|
||||
|
|
|
|||
|
|
@ -12392,6 +12392,14 @@ void dlgTriggerEditor::doCleanReset()
|
|||
|
||||
void dlgTriggerEditor::runScheduledCleanReset()
|
||||
{
|
||||
if (!mpHost) {
|
||||
// The profile went away between doCleanReset() scheduling this and the timer firing,
|
||||
// which is the order a teardown destroys them in. There is nothing left to repopulate
|
||||
// from, and clearing the tree widgets below would re-enter the editor through
|
||||
// selectionChanged to read the theme and font off the Host that has just gone.
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear all current item pointers BEFORE attempting to save or clear tree widgets
|
||||
// to prevent heap-use-after-free when the tree widgets are cleared
|
||||
mpCurrentTriggerItem = nullptr;
|
||||
|
|
|
|||
|
|
@ -112,8 +112,22 @@ set_tests_properties(InsertTextCapTest PROPERTIES TIMEOUT 300)
|
|||
set_tests_properties(LogRestartDuplicateLineTest PROPERTIES TIMEOUT 300)
|
||||
|
||||
# TMediaLoopTest probes the audio backend and creates a fresh profile per test method,
|
||||
# and each clip has to be waited out in real time, so it needs a longer timeout
|
||||
# and each clip has to be waited out in real time, so it needs a longer timeout.
|
||||
# QTEST_MAIN does not run src/main.cpp, so pin QT_MEDIA_BACKEND to what main.cpp picks for
|
||||
# the shipped application - otherwise the tests silently exercise Qt's default backend for
|
||||
# the platform rather than the one users actually get.
|
||||
if(APPLE)
|
||||
set(mediaLoopTestBackend "darwin")
|
||||
elseif(WIN32)
|
||||
set(mediaLoopTestBackend "ffmpeg")
|
||||
else()
|
||||
set(mediaLoopTestBackend "")
|
||||
endif()
|
||||
set_tests_properties(TMediaLoopTest PROPERTIES TIMEOUT 300)
|
||||
if(mediaLoopTestBackend)
|
||||
# APPEND, so the environment set by the loop above stays in force rather than being replaced
|
||||
set_property(TEST TMediaLoopTest APPEND PROPERTY ENVIRONMENT "QT_MEDIA_BACKEND=${mediaLoopTestBackend}")
|
||||
endif()
|
||||
|
||||
# The round-trip tests boot a full mudlet instance and save/reload profile and
|
||||
# map data, so they need a longer timeout
|
||||
|
|
|
|||
|
|
@ -43,28 +43,47 @@ void initializeQRCResourcesForMediaLoop();
|
|||
|
||||
using namespace std::chrono_literals;
|
||||
|
||||
// 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)
|
||||
|
||||
/*
|
||||
* Regression guard for "Client.Media loops=-1 plays once" (issue #9566).
|
||||
* 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:
|
||||
*
|
||||
* Qt's FFmpeg backend ends a track by emitting StoppedState first and only then
|
||||
* EndOfMedia, and it skips the EndOfMedia notification if the playback engine
|
||||
* disappeared in between. Mudlet restarts a loop from its EndOfMedia handler, so
|
||||
* when the StoppedState handler cleared the source immediately (added by #9237 as
|
||||
* a cleanup measure) it destroyed the engine, EndOfMedia never arrived and an
|
||||
* indefinitely looping track played exactly once.
|
||||
* - 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.
|
||||
*
|
||||
* The cleanup is therefore deferred by one event-loop turn and re-checks the
|
||||
* playback state, which lets a loop restart claim the player first. Both halves of
|
||||
* that contract are covered here: a looping track must survive the stop/restart
|
||||
* cycle, and a one-shot track must still be torn down so #9237 is not regressed.
|
||||
*
|
||||
* Both assertions depend on the platform actually decoding a clip through to
|
||||
* EndOfMedia. Some setups cannot - notably the macOS darwin backend under
|
||||
* QT_QPA_PLATFORM=offscreen, which the functional suite sets, stalls in
|
||||
* LoadingMedia indefinitely. TMedia reports a stalled player as still playing, so
|
||||
* without a capability check the looping assertion would pass on broken code too.
|
||||
* Each test therefore probes a plain QMediaPlayer first and skips if the backend
|
||||
* cannot finish a clip.
|
||||
* 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.
|
||||
*/
|
||||
class TMediaLoopTest : public QObject
|
||||
{
|
||||
|
|
@ -72,6 +91,7 @@ class TMediaLoopTest : public QObject
|
|||
|
||||
private:
|
||||
TelnetServerStub* mpServer = nullptr;
|
||||
Host* mpHost = nullptr;
|
||||
const QString mHostname = "Test-Media-Loop";
|
||||
const QString mPort = "4012";
|
||||
const QString mLocalhost = "localhost";
|
||||
|
|
@ -80,16 +100,19 @@ private:
|
|||
// artefact of start-up latency, short enough to loop several times quickly.
|
||||
static constexpr int clipMs = 400;
|
||||
|
||||
// Probed once, because the probe has to wait out a whole clip and the suite gives
|
||||
// every functional test a single wall-clock budget for all of its slots.
|
||||
QTemporaryDir mProbeDir;
|
||||
// Set when the backend cannot decode a clip through to EndOfMedia at all.
|
||||
// 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.
|
||||
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
|
||||
// re-sourced can never be mistaken for a stopped one.
|
||||
// re-sourced can never be mistaken for a stopped one - which is the whole race the
|
||||
// claim counter exists to settle.
|
||||
QString mSynchronousStartReason;
|
||||
// Set when the backend does not report an undecodable file as an error.
|
||||
QString mNoLoadErrorReason;
|
||||
|
||||
private slots:
|
||||
void initTestCase()
|
||||
|
|
@ -115,17 +138,14 @@ private slots:
|
|||
// was never delivered and the player dropped out of the playing set for good.
|
||||
void test_loopingTrackKeepsPlayingPastFirstPass()
|
||||
{
|
||||
if (!mCannotPlayReason.isEmpty()) {
|
||||
QSKIP(qPrintable(mCannotPlayReason));
|
||||
}
|
||||
if (!mWrongOrderReason.isEmpty()) {
|
||||
QSKIP(qPrintable(mWrongOrderReason));
|
||||
}
|
||||
SKIP_OR_FAIL_WITHOUT(mCannotPlayReason);
|
||||
SKIP_OR_FAIL_WITHOUT(mWrongOrderReason);
|
||||
|
||||
auto* media = startProfileAndGetMedia();
|
||||
QVERIFY(media);
|
||||
|
||||
const QString fileName = writeClip(qsl("loop.wav"));
|
||||
QVERIFY(!fileName.isEmpty());
|
||||
|
||||
TMediaData data = clipData(fileName);
|
||||
data.setMediaLoops(TMediaData::MediaLoopsRepeat);
|
||||
|
|
@ -133,26 +153,63 @@ private slots:
|
|||
|
||||
QVERIFY2(waitForPlaying(media, fileName), "The looping track never started playing.");
|
||||
|
||||
// Span several passes so a single missed restart cannot pass by luck.
|
||||
QTest::qWait(clipMs * 4);
|
||||
// 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(playing(media, fileName), "A loops=-1 track stopped after its first pass - the StoppedState cleanup suppressed EndOfMedia and the loop never restarted.");
|
||||
QVERIFY2(waitForPlaying(media, fileName, 2s), "A loops=-1 track stopped after its first pass - the StoppedState cleanup suppressed EndOfMedia and the loop never restarted.");
|
||||
}
|
||||
|
||||
// The deferred cleanup must still fire for a genuinely finished track, otherwise
|
||||
// the resource release that #9237 added would be lost. Releasing the source is the
|
||||
// only observable effect of the deferred stop cleanup: playingMedia() drops a player
|
||||
// the moment it reports StoppedState, well before that cleanup runs.
|
||||
void test_oneShotTrackIsCleanedUpWhenItFinishes()
|
||||
// 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()
|
||||
{
|
||||
if (!mCannotPlayReason.isEmpty()) {
|
||||
QSKIP(qPrintable(mCannotPlayReason));
|
||||
}
|
||||
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, 2s), "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(10s));
|
||||
|
||||
QVERIFY2(cleanedUp, "A loops=3 track never finished and released its source - the deferred cleanup did not take over from the last pass.");
|
||||
}
|
||||
|
||||
// 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.
|
||||
void test_oneShotTrackIsCleanedUpWhenItFinishes()
|
||||
{
|
||||
SKIP_OR_FAIL_WITHOUT(mCannotPlayReason);
|
||||
|
||||
auto* media = startProfileAndGetMedia();
|
||||
QVERIFY(media);
|
||||
|
||||
watchMediaFinished();
|
||||
|
||||
const QString fileName = writeClip(qsl("oneshot.wav"));
|
||||
QVERIFY(!fileName.isEmpty());
|
||||
|
||||
TMediaData data = clipData(fileName);
|
||||
data.setMediaLoops(TMediaData::MediaLoopsDefault);
|
||||
|
|
@ -167,32 +224,174 @@ private slots:
|
|||
QDeadlineTimer(10s));
|
||||
|
||||
QVERIFY2(cleanedUp, "A finished one-shot track never released its media source - the deferred cleanup did not run.");
|
||||
|
||||
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(10s));
|
||||
|
||||
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(10s));
|
||||
|
||||
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(10s));
|
||||
|
||||
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.");
|
||||
}
|
||||
|
||||
// A player that is handed to a different track in the same event-loop turn, as
|
||||
// stopMusic() followed by playMusic{} in one script does, must keep the new source.
|
||||
// The pending cleanup belongs to the track that stopped, and on this backend the
|
||||
// player still reads as stopped while it loads the new one.
|
||||
// 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.
|
||||
void test_reusedPlayerKeepsTheTrackThatClaimedIt()
|
||||
{
|
||||
if (!mCannotPlayReason.isEmpty()) {
|
||||
QSKIP(qPrintable(mCannotPlayReason));
|
||||
}
|
||||
if (!mSynchronousStartReason.isEmpty()) {
|
||||
QSKIP(qPrintable(mSynchronousStartReason));
|
||||
}
|
||||
SKIP_OR_FAIL_WITHOUT(mCannotStartReason);
|
||||
SKIP_OR_FAIL_WITHOUT(mSynchronousStartReason);
|
||||
|
||||
auto* media = startProfileAndGetMedia();
|
||||
QVERIFY(media);
|
||||
|
||||
const QString firstFile = writeClip(qsl("first.wav"));
|
||||
const QString secondFile = writeClip(qsl("second.wav"));
|
||||
QVERIFY(!firstFile.isEmpty() && !secondFile.isEmpty());
|
||||
|
||||
TMediaData first = clipData(firstFile);
|
||||
first.setMediaLoops(TMediaData::MediaLoopsRepeat);
|
||||
media->playMedia(first);
|
||||
|
||||
QVERIFY2(waitForPlaying(media, firstFile), "The first track never started playing.");
|
||||
QVERIFY2(waitForPlaybackStarted(media, firstFile), "The first track never started playing.");
|
||||
|
||||
const int playerCount = media->mediaPlayerCount();
|
||||
|
||||
TMediaData stopFirst = clipData(firstFile);
|
||||
media->stopMedia(stopFirst);
|
||||
|
|
@ -203,34 +402,35 @@ private slots:
|
|||
|
||||
QVERIFY2(waitForPlaying(media, secondFile), "The replacement track never started playing.");
|
||||
|
||||
// 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);
|
||||
|
||||
// Past the turn the stopped track's cleanup was scheduled for.
|
||||
QTest::qWait(clipMs);
|
||||
|
||||
QVERIFY2(playing(media, secondFile), "The replacement track was cut off - the previous track's deferred cleanup cleared the source out from under it.");
|
||||
QVERIFY2(waitForPlaying(media, secondFile, 2s), "The replacement track was cut off - the previous track's deferred cleanup cleared the source out from under it.");
|
||||
}
|
||||
|
||||
// continue=false restarts a track by stopping it and re-sourcing the same player
|
||||
// inside one call. That player is matched, not claimed, so nothing in the reuse path
|
||||
// tells the pending cleanup that the track it belongs to has already been replaced.
|
||||
// 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.
|
||||
void test_restartedTrackKeepsItsNewSource()
|
||||
{
|
||||
if (!mCannotPlayReason.isEmpty()) {
|
||||
QSKIP(qPrintable(mCannotPlayReason));
|
||||
}
|
||||
if (!mSynchronousStartReason.isEmpty()) {
|
||||
QSKIP(qPrintable(mSynchronousStartReason));
|
||||
}
|
||||
SKIP_OR_FAIL_WITHOUT(mCannotStartReason);
|
||||
SKIP_OR_FAIL_WITHOUT(mSynchronousStartReason);
|
||||
|
||||
auto* media = startProfileAndGetMedia();
|
||||
QVERIFY(media);
|
||||
|
||||
const QString fileName = writeClip(qsl("restart.wav"));
|
||||
QVERIFY(!fileName.isEmpty());
|
||||
|
||||
TMediaData data = clipData(fileName);
|
||||
data.setMediaLoops(TMediaData::MediaLoopsRepeat);
|
||||
media->playMedia(data);
|
||||
|
||||
QVERIFY2(waitForPlaying(media, fileName), "The track never started playing.");
|
||||
QVERIFY2(waitForPlaybackStarted(media, fileName), "The track never started playing.");
|
||||
|
||||
TMediaData restart = clipData(fileName);
|
||||
restart.setMediaLoops(TMediaData::MediaLoopsRepeat);
|
||||
|
|
@ -240,13 +440,14 @@ private slots:
|
|||
// Past the turn the stop inside that restart scheduled its cleanup for.
|
||||
QTest::qWait(clipMs);
|
||||
|
||||
QVERIFY2(playing(media, fileName), "A restarted track was cut off - the cleanup deferred by its own stop cleared the source it had just been given.");
|
||||
QVERIFY2(waitForPlaying(media, fileName, 2s), "A restarted track was cut off - the cleanup deferred by its own stop cleared the source it had just been given.");
|
||||
}
|
||||
|
||||
void cleanup()
|
||||
{
|
||||
delete mpServer;
|
||||
mpServer = nullptr;
|
||||
mpHost = nullptr;
|
||||
deleteProfileDirectory(mHostname);
|
||||
delete mudlet::self();
|
||||
}
|
||||
|
|
@ -265,32 +466,60 @@ private:
|
|||
QTest::qFail("Host has no TMedia instance.", __FILE__, __LINE__);
|
||||
return nullptr;
|
||||
}
|
||||
mpHost = host;
|
||||
return media;
|
||||
}
|
||||
|
||||
// Records what this backend is and is not able to demonstrate.
|
||||
//
|
||||
// - A backend that stalls in LoadingMedia (macOS darwin under
|
||||
// QT_QPA_PLATFORM=offscreen, which the functional suite sets) never stops, and
|
||||
// TMedia reports a stalled player as still playing, so nothing below is
|
||||
// observable at all.
|
||||
// - A backend that emits EndOfMedia *before* StoppedState (macOS darwin under
|
||||
// cocoa) restarts a loop before any cleanup can run, so issue #9566 cannot occur
|
||||
// and the looping assertion would hold on broken code. Only the
|
||||
// StoppedState-first ordering (Qt's FFmpeg backend) can reproduce it. The other
|
||||
// two tests turn on an explicit stop, so they hold on any backend that plays.
|
||||
// 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 = 10s) 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.
|
||||
void probeBackend()
|
||||
{
|
||||
if (!mProbeDir.isValid()) {
|
||||
mCannotPlayReason = qsl("Could not create a temporary directory for the backend probe.");
|
||||
return;
|
||||
// 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.");
|
||||
}
|
||||
|
||||
const QString path = qsl("%1/probe.wav").arg(mProbeDir.path());
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::WriteOnly)) {
|
||||
mCannotPlayReason = qsl("Could not write the backend probe clip.");
|
||||
return;
|
||||
QFAIL("Could not write the backend probe clip.");
|
||||
}
|
||||
file.write(wavBytes());
|
||||
file.close();
|
||||
|
|
@ -302,12 +531,16 @@ private:
|
|||
|
||||
bool sawEndOfMedia = false;
|
||||
bool stoppedCameFirst = false;
|
||||
bool sawPlaying = false;
|
||||
connect(&probe, &QMediaPlayer::mediaStatusChanged, this, [&](QMediaPlayer::MediaStatus status) {
|
||||
if (status == QMediaPlayer::EndOfMedia) {
|
||||
sawEndOfMedia = true;
|
||||
}
|
||||
});
|
||||
connect(&probe, &QMediaPlayer::playbackStateChanged, this, [&](QMediaPlayer::PlaybackState state) {
|
||||
if (state == QMediaPlayer::PlayingState) {
|
||||
sawPlaying = true;
|
||||
}
|
||||
if (state == QMediaPlayer::StoppedState && !sawEndOfMedia) {
|
||||
stoppedCameFirst = true;
|
||||
}
|
||||
|
|
@ -326,17 +559,83 @@ private:
|
|||
QDeadlineTimer(10s));
|
||||
probe.stop();
|
||||
|
||||
// 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());
|
||||
}
|
||||
|
||||
if (!finished) {
|
||||
mCannotPlayReason = qsl("This Qt Multimedia backend cannot decode a clip to completion here (it stalls before EndOfMedia), so media playback behaviour cannot be observed.");
|
||||
// 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());
|
||||
return;
|
||||
}
|
||||
|
||||
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.");
|
||||
}
|
||||
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.");
|
||||
|
||||
// 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(10s));
|
||||
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())));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -351,8 +650,8 @@ private:
|
|||
return data;
|
||||
}
|
||||
|
||||
// TMedia reports a player only while it is actually playing (or still loading),
|
||||
// which is the observable this regression turns on.
|
||||
// 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.
|
||||
bool playing(TMedia* media, const QString& fileName) const
|
||||
{
|
||||
TMediaData criteria = clipData(fileName);
|
||||
|
|
@ -368,9 +667,24 @@ private:
|
|||
QDeadlineTimer(timeout));
|
||||
}
|
||||
|
||||
// Writes a silent 16-bit mono PCM WAV into the profile media directory. Silence is
|
||||
// fine: the test asserts on playback state transitions, not on what is heard.
|
||||
QString writeClip(const QString& fileName) const
|
||||
// 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 = 10s)
|
||||
{
|
||||
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
|
||||
{
|
||||
const QString mediaPath = mudlet::getMudletPath(enums::profileMediaPath, mHostname);
|
||||
if (!QDir().mkpath(mediaPath)) {
|
||||
|
|
@ -383,7 +697,7 @@ private:
|
|||
QTest::qFail("Could not write the test media file.", __FILE__, __LINE__);
|
||||
return {};
|
||||
}
|
||||
file.write(wavBytes());
|
||||
file.write(contents);
|
||||
file.close();
|
||||
return fileName;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue