mirror of
https://github.com/steredbits/pelagia.git
synced 2026-10-17 21:01:34 +02:00
463 lines
18 KiB
C++
463 lines
18 KiB
C++
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
// Copyright (C) 2026 steredbits and Pelagia contributors
|
|
// Player screen in the interface: opening outside the display
|
|
// loop, states (loading, playing, paused), seek, asynchronous stop
|
|
// with stop report then page refresh, end of the movie,
|
|
// opening failure, cancellation during loading, application
|
|
// closing in the middle of playback. Synthesized MP4 file, no network.
|
|
|
|
#include <stdlib.h>
|
|
|
|
#include <chrono>
|
|
#include <functional>
|
|
#include <string>
|
|
#include <thread>
|
|
|
|
#include "media_synth.h"
|
|
#include "test_framework.h"
|
|
#include "ui/screens/detail_screen.h"
|
|
#include "ui/screens/player_screen.h"
|
|
#include "ui_harness.h"
|
|
|
|
using namespace uitest;
|
|
|
|
namespace {
|
|
|
|
uint64_t real_ms() {
|
|
return static_cast<uint64_t>(std::chrono::duration_cast<std::chrono::milliseconds>(
|
|
std::chrono::steady_clock::now().time_since_epoch())
|
|
.count());
|
|
}
|
|
|
|
// Runs the application in real time until pred() (or timeout).
|
|
bool run_until(Harness& h, const std::function<bool()>& pred, int timeout_ms = 10000) {
|
|
const uint64_t end = real_ms() + timeout_ms;
|
|
while (real_ms() < end) {
|
|
h.api.run_all();
|
|
h.app->update(h.now = real_ms());
|
|
if (pred()) return true;
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(4));
|
|
}
|
|
return false;
|
|
}
|
|
|
|
std::string g_media_long; // 20 s
|
|
std::string g_media_short; // 1 s
|
|
|
|
// Home -> Movies -> "Crazy" (playback started at 20:00) -> page.
|
|
ui::DetailScreen* open_detail(Harness& h) {
|
|
save_session(h, "good");
|
|
h.app->start("");
|
|
h.settle();
|
|
h.press(InputEvent::Down);
|
|
h.press(InputEvent::Ok);
|
|
h.settle();
|
|
h.press(InputEvent::Right, 2);
|
|
h.press(InputEvent::Ok);
|
|
h.settle();
|
|
CHECK(h.screen() == "detail");
|
|
return h.top<ui::DetailScreen>();
|
|
}
|
|
|
|
ui::PlayerScreen* player_screen(Harness& h) {
|
|
return h.screen() == "player" ? h.top<ui::PlayerScreen>() : nullptr;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
static void test_play_pause_seek_stop() {
|
|
Harness h;
|
|
h.backend.media_path = g_media_long;
|
|
open_detail(h);
|
|
const int item_calls = h.backend.item_calls;
|
|
h.press(InputEvent::Right); // "Play from the beginning"
|
|
h.press(InputEvent::Ok);
|
|
h.app->update(h.now = real_ms());
|
|
CHECK(h.screen() == "player");
|
|
CHECK_EQ(h.backend.last_play_start, 0);
|
|
ui::PlayerScreen* ps = player_screen(h);
|
|
CHECK(ps != nullptr);
|
|
if (!ps) return;
|
|
CHECK(h.app->fast_ticks());
|
|
// The opening runs on a thread: the loop goes on (no blocking).
|
|
CHECK(run_until(h, [&] { return ps->session()->phase() == ui::PlaybackSession::Phase::Playing; }));
|
|
CHECK(run_until(h, [&] { return ps->session()->player().position_ms() > 400; }));
|
|
CHECK(ps->shows_video());
|
|
CHECK_EQ(h.backend.reports->started.load(), 1);
|
|
// Banner: visible after a key press, hidden 4 s later (during playback).
|
|
h.press(InputEvent::Up);
|
|
CHECK(ps->overlay_visible(h.now));
|
|
CHECK(!ps->overlay_visible(h.now + 5000));
|
|
h.press(InputEvent::PlayPause);
|
|
CHECK(ps->session()->player().state() == player::PlayerState::Paused);
|
|
CHECK(ps->overlay_visible(h.now + 60000)); // always visible while paused
|
|
h.press(InputEvent::Ok);
|
|
CHECK(ps->session()->player().state() == player::PlayerState::Playing);
|
|
// Left/right = 10 s seek in the player (no navigation).
|
|
const int64_t before = ps->session()->player().position_ms();
|
|
h.press(InputEvent::Right);
|
|
CHECK(ps->session()->player().position_ms() >= before + 9000);
|
|
CHECK(run_until(h, [&] { return !ps->session()->player().seek_in_flight(); }));
|
|
h.press(InputEvent::SeekBackLong); // -60 s: clamped to 0
|
|
CHECK(ps->session()->player().position_ms() < 2000);
|
|
CHECK(run_until(h, [&] { return !ps->busy(); }));
|
|
|
|
// Back: background stop, stop report, then page re-read.
|
|
h.press(InputEvent::Back);
|
|
// Checked before update(): the end of the stop is only taken into account when
|
|
// the main queue is drained (update), and the stop thread may already
|
|
// have finished (same cause as in test_destroy_while_stopping).
|
|
CHECK(h.screen() == "detail");
|
|
CHECK(h.app->playback_stopping());
|
|
h.app->update(h.now = real_ms());
|
|
CHECK(run_until(h, [&] { return h.app->retiring_count() == 0; }, 8000));
|
|
CHECK(run_until(h, [&] { return h.backend.item_calls > item_calls; }));
|
|
CHECK_EQ(h.backend.reports->stopped.load(), 1);
|
|
CHECK(!h.app->playback_stopping());
|
|
CHECK(!h.app->fast_ticks());
|
|
}
|
|
|
|
static void test_resume_position() {
|
|
Harness h;
|
|
h.backend.media_path = g_media_long;
|
|
ui::DetailScreen* d = open_detail(h);
|
|
CHECK(d->buttons()[0] == "Resume at 20:00");
|
|
h.press(InputEvent::Ok); // "Resume from 20:00"
|
|
CHECK_EQ(h.backend.last_play_start, 20 * 60 * 1000);
|
|
h.press(InputEvent::Back);
|
|
CHECK(run_until(h, [&] { return h.app->retiring_count() == 0; }, 8000));
|
|
}
|
|
|
|
static void test_end_of_media() {
|
|
Harness h;
|
|
h.backend.media_path = g_media_short;
|
|
open_detail(h);
|
|
h.press(InputEvent::Right);
|
|
h.press(InputEvent::Ok);
|
|
// End of the movie: automatic return to the page, stop report sent.
|
|
CHECK(run_until(h, [&] { return h.screen() == "detail"; }, 15000));
|
|
CHECK(run_until(h, [&] { return h.app->retiring_count() == 0; }, 8000));
|
|
CHECK_EQ(h.backend.reports->stopped.load(), 1);
|
|
}
|
|
|
|
static void test_open_failure() {
|
|
Harness h; // media_path inexistant
|
|
open_detail(h);
|
|
h.press(InputEvent::Right);
|
|
h.press(InputEvent::Ok);
|
|
h.app->update(h.now = real_ms());
|
|
ui::PlayerScreen* ps = player_screen(h);
|
|
CHECK(ps != nullptr);
|
|
if (!ps) return;
|
|
CHECK(run_until(h, [&] { return ps->open_failed(); }));
|
|
h.press(InputEvent::PlayPause); // no effect
|
|
CHECK(h.screen() == "player");
|
|
h.press(InputEvent::Back);
|
|
CHECK(run_until(h, [&] { return h.app->retiring_count() == 0; }));
|
|
CHECK(h.screen() == "detail");
|
|
CHECK_EQ(h.backend.reports->started.load(), 0);
|
|
CHECK_EQ(h.backend.reports->stopped.load(), 0); // never started
|
|
}
|
|
|
|
static void test_cancel_while_opening() {
|
|
Harness h;
|
|
h.backend.media_path = g_media_long;
|
|
h.backend.stuck_open = true; // the server does not answer
|
|
open_detail(h);
|
|
h.press(InputEvent::Ok);
|
|
h.app->update(h.now = real_ms());
|
|
ui::PlayerScreen* ps = player_screen(h);
|
|
CHECK(ps != nullptr && ps->opening());
|
|
CHECK(ps != nullptr && ps->busy());
|
|
// The display loop is not blocked during the opening.
|
|
const uint64_t t0 = real_ms();
|
|
for (int i = 0; i < 10; ++i) h.app->update(h.now = real_ms());
|
|
CHECK(real_ms() - t0 < 200);
|
|
h.press(InputEvent::Back); // annuler
|
|
h.app->update(h.now = real_ms());
|
|
CHECK(h.screen() == "detail");
|
|
const uint64_t t1 = real_ms();
|
|
CHECK(run_until(h, [&] { return h.app->retiring_count() == 0; }, 3000));
|
|
CHECK(real_ms() - t1 < 1000); // abort_open: no waiting for the timeout
|
|
}
|
|
|
|
static void test_quit_while_playing() {
|
|
std::shared_ptr<ReportLog> log;
|
|
{
|
|
Harness h;
|
|
h.backend.media_path = g_media_long;
|
|
log = h.backend.reports;
|
|
open_detail(h);
|
|
h.press(InputEvent::Right);
|
|
h.press(InputEvent::Ok);
|
|
ui::PlayerScreen* ps = nullptr;
|
|
CHECK(run_until(h, [&] {
|
|
ps = player_screen(h);
|
|
// The player is only readable once the opening (other thread) is finished.
|
|
return ps && ps->session()->phase() == ui::PlaybackSession::Phase::Playing &&
|
|
ps->session()->player().position_ms() > 300;
|
|
}));
|
|
h.press(InputEvent::Quit);
|
|
CHECK(h.app->quit_requested());
|
|
} // App destroyed in the middle of playback: synchronous stop, position reported
|
|
CHECK_EQ(log->stopped.load(), 1);
|
|
CHECK(log->last_stop_position.load() > 0);
|
|
}
|
|
|
|
// The application closes while the stop of a playback (blocked opening)
|
|
// is still in progress: the destructor and the stop thread must not
|
|
// join the same thread (race detected by ThreadSanitizer).
|
|
static void test_destroy_while_stopping() {
|
|
for (int i = 0; i < 5; ++i) {
|
|
Harness h;
|
|
h.backend.media_path = g_media_long;
|
|
h.backend.stuck_open = true;
|
|
open_detail(h);
|
|
h.press(InputEvent::Ok);
|
|
h.app->update(h.now = real_ms());
|
|
CHECK(h.screen() == "player");
|
|
h.press(InputEvent::Back); // stop started in the background
|
|
// Checked before update(): the session only leaves the list when
|
|
// the main queue is drained (update), and the stop thread may already have finished
|
|
// (blocked opening interrupted right away); checking after update()
|
|
// made the test unstable on a loaded machine (CI).
|
|
CHECK(h.app->retiring_count() == 1);
|
|
h.app->update(h.now = real_ms());
|
|
} // ~Harness: the application is destroyed during the stop
|
|
}
|
|
|
|
static api::MediaStreamInfo track_stream(int index, api::StreamKind kind, const char* lang,
|
|
const char* codec, bool text = false) {
|
|
api::MediaStreamInfo st;
|
|
st.index = index;
|
|
st.kind = kind;
|
|
st.language = lang;
|
|
st.codec = codec;
|
|
st.channels = kind == api::StreamKind::Audio ? 6 : 0;
|
|
st.is_text = text;
|
|
st.supports_external = text;
|
|
return st;
|
|
}
|
|
|
|
// Player track menu: Options -> Audio / Subtitles, stream reopening
|
|
// for audio and burned-in subtitles, immediate change for text,
|
|
// reports that carry the tracks.
|
|
static void test_track_menu_in_player() {
|
|
Harness h;
|
|
h.backend.media_path = g_media_long;
|
|
api::MediaSourceInfo src;
|
|
src.id = "src-pistes";
|
|
src.streams.push_back(track_stream(1, api::StreamKind::Audio, "eng", "eac3"));
|
|
src.streams.push_back(track_stream(2, api::StreamKind::Audio, "fre", "eac3"));
|
|
src.streams.push_back(track_stream(3, api::StreamKind::Subtitle, "fre", "subrip", true));
|
|
src.streams.push_back(track_stream(4, api::StreamKind::Subtitle, "fre", "PGSSUB"));
|
|
src.server_defaults = true;
|
|
src.default_audio_index = 1;
|
|
src.default_subtitle_index = 3;
|
|
h.backend.movies[3].media_sources.push_back(src);
|
|
open_detail(h);
|
|
h.press(InputEvent::Right); // "Play from the beginning"
|
|
h.press(InputEvent::Ok);
|
|
h.app->update(h.now = real_ms());
|
|
ui::PlayerScreen* ps = player_screen(h);
|
|
CHECK(ps != nullptr);
|
|
if (!ps) return;
|
|
CHECK_EQ(h.backend.last_play_tracks.audio_index, 1);
|
|
CHECK_EQ(h.backend.last_play_tracks.subtitle_index, 3);
|
|
CHECK(run_until(h, [&] { return ps->session()->phase() == ui::PlaybackSession::Phase::Playing; }));
|
|
CHECK(run_until(h, [&] { return ps->session()->player().position_ms() > 600; }));
|
|
// The first reports already carry the tracks.
|
|
CHECK(run_until(h, [&] { return h.backend.reports->last_audio.load() == 1; }));
|
|
CHECK_EQ(h.backend.reports->last_subtitle.load(), 3);
|
|
|
|
// Options: two entries that recall the current tracks.
|
|
h.press(InputEvent::Menu);
|
|
CHECK(h.app->menu_open());
|
|
CHECK_EQ(h.app->menu_items().size(), 2u);
|
|
CHECK(h.app->menu_items()[0].label == "Audio: English · E-AC3 5.1");
|
|
CHECK(h.app->menu_items()[1].label == "Subtitles: French · SRT");
|
|
// Audio: list, current track checked; French reopens the stream.
|
|
h.press(InputEvent::Ok);
|
|
CHECK(h.app->menu_open());
|
|
CHECK_EQ(h.app->menu_items().size(), 2u);
|
|
CHECK(h.app->menu_items()[0].checked && h.app->menu_index() == 0);
|
|
const int64_t before = ps->session()->player().position_ms();
|
|
h.press(InputEvent::Down);
|
|
h.press(InputEvent::Ok);
|
|
CHECK(!h.app->menu_open());
|
|
CHECK_EQ(h.backend.reports->set_tracks_calls.load(), 1);
|
|
CHECK(ps->session()->player().seek_in_flight()); // reopening at the current position
|
|
CHECK(ps->track_change_pending());
|
|
CHECK(run_until(h, [&] { return !ps->track_change_pending(); }));
|
|
CHECK(ps->session()->player().position_ms() >= before - 500);
|
|
api::TrackSelection now;
|
|
CHECK(ps->session()->tracks(&now));
|
|
CHECK_EQ(now.audio_index, 2);
|
|
CHECK_EQ(now.subtitle_index, 3);
|
|
// The server is notified right away (Progress with the new tracks).
|
|
CHECK(run_until(h, [&] { return h.backend.reports->last_audio.load() == 2; }));
|
|
|
|
// Subtitles rendered by the client (text): "None" without reopening.
|
|
h.press(InputEvent::Menu);
|
|
CHECK(h.app->menu_items()[0].label == "Audio: French · E-AC3 5.1");
|
|
h.press(InputEvent::Down);
|
|
h.press(InputEvent::Ok);
|
|
CHECK_EQ(h.app->menu_items().size(), 3u); // None, SRT, burned-in PGS
|
|
CHECK(h.app->menu_items()[2].label == "French · PGS · burned in");
|
|
CHECK_EQ(h.app->menu_index(), 1);
|
|
h.press(InputEvent::Up);
|
|
h.press(InputEvent::Ok); // Aucun
|
|
CHECK_EQ(h.backend.reports->set_tracks_calls.load(), 2);
|
|
CHECK(!ps->session()->player().seek_in_flight());
|
|
CHECK(!ps->track_change_pending());
|
|
CHECK(run_until(h, [&] { return h.backend.reports->last_subtitle.load() == -1; }));
|
|
|
|
// Image: burned in by the server, hence reopening.
|
|
h.press(InputEvent::Menu);
|
|
h.press(InputEvent::Down);
|
|
h.press(InputEvent::Ok);
|
|
h.press(InputEvent::Down, 2);
|
|
h.press(InputEvent::Ok); // PGS
|
|
CHECK_EQ(h.backend.reports->set_tracks_calls.load(), 3);
|
|
CHECK(ps->track_change_pending());
|
|
CHECK(run_until(h, [&] { return !ps->track_change_pending(); }));
|
|
CHECK(run_until(h, [&] { return h.backend.reports->last_subtitle.load() == 4; }));
|
|
|
|
// Same choice: nothing changes. Back in a menu: cancels.
|
|
h.press(InputEvent::Menu);
|
|
h.press(InputEvent::Down);
|
|
h.press(InputEvent::Ok);
|
|
h.press(InputEvent::Ok);
|
|
CHECK_EQ(h.backend.reports->set_tracks_calls.load(), 3);
|
|
h.press(InputEvent::Menu);
|
|
h.press(InputEvent::Back);
|
|
CHECK(!h.app->menu_open());
|
|
h.press(InputEvent::Back); // playback stop
|
|
CHECK(run_until(h, [&] { return h.screen() == "detail"; }));
|
|
}
|
|
|
|
// Text subtitles rendered by the client: downloaded once per track outside
|
|
// the display loop, instant change (no stream reopening).
|
|
static void test_client_subtitles() {
|
|
Harness h;
|
|
h.backend.media_path = g_media_long;
|
|
api::MediaSourceInfo src;
|
|
src.id = "src-st";
|
|
src.streams.push_back(track_stream(1, api::StreamKind::Audio, "eng", "eac3"));
|
|
src.streams.push_back(track_stream(3, api::StreamKind::Subtitle, "fre", "subrip", true));
|
|
src.streams.push_back(track_stream(4, api::StreamKind::Subtitle, "eng", "subrip", true));
|
|
src.streams.push_back(track_stream(5, api::StreamKind::Subtitle, "ger", "subrip", true));
|
|
src.server_defaults = true;
|
|
src.default_audio_index = 1;
|
|
src.default_subtitle_index = 3;
|
|
h.backend.movies[3].media_sources.push_back(src);
|
|
h.backend.failing_subtitle = 5;
|
|
open_detail(h);
|
|
h.press(InputEvent::Right);
|
|
h.press(InputEvent::Ok);
|
|
h.app->update(h.now = real_ms());
|
|
ui::PlayerScreen* ps = player_screen(h);
|
|
CHECK(ps != nullptr);
|
|
if (!ps) return;
|
|
CHECK(run_until(h, [&] { return ps->session()->phase() == ui::PlaybackSession::Phase::Playing; }));
|
|
// Default track (client): downloaded, nothing is requested from the stream.
|
|
CHECK_EQ(ps->wanted_subtitle(), 3);
|
|
CHECK(run_until(h, [&] { return ps->subtitles_loaded(3); }));
|
|
CHECK_EQ(h.backend.subtitle_calls.load(), 1);
|
|
CHECK(h.backend.last_subtitle_request == std::string(h.backend.movies[3].id) + "/src-st/3");
|
|
CHECK(ps->subtitle_lines(500).empty());
|
|
CHECK(ps->subtitle_lines(2000) == std::vector<std::string>({"Bonjour"}));
|
|
CHECK(ps->subtitle_lines(4000) == std::vector<std::string>({"Deux", "lignes"}));
|
|
CHECK(ps->subtitle_lines(5000).empty());
|
|
|
|
// Other text track: downloaded in turn, without reopening the stream.
|
|
h.press(InputEvent::Menu);
|
|
h.press(InputEvent::Down);
|
|
h.press(InputEvent::Ok);
|
|
h.press(InputEvent::Down); // anglais (index 4)
|
|
h.press(InputEvent::Ok);
|
|
CHECK_EQ(ps->wanted_subtitle(), 4);
|
|
CHECK(!ps->session()->player().seek_in_flight());
|
|
CHECK(!ps->track_change_pending());
|
|
CHECK(run_until(h, [&] { return ps->subtitles_loaded(4); }));
|
|
CHECK_EQ(h.backend.subtitle_calls.load(), 2);
|
|
CHECK(!ps->subtitles_loading());
|
|
|
|
// "None": nothing on screen anymore; back to an already loaded track: no new download.
|
|
h.press(InputEvent::Menu);
|
|
h.press(InputEvent::Down);
|
|
h.press(InputEvent::Ok);
|
|
h.press(InputEvent::Up, 5);
|
|
h.press(InputEvent::Ok);
|
|
CHECK_EQ(ps->wanted_subtitle(), -1);
|
|
CHECK(ps->subtitle_lines(2000).empty());
|
|
h.press(InputEvent::Menu);
|
|
h.press(InputEvent::Down);
|
|
h.press(InputEvent::Ok);
|
|
h.press(InputEvent::Down);
|
|
h.press(InputEvent::Ok); // French, already in memory
|
|
CHECK_EQ(ps->wanted_subtitle(), 3);
|
|
CHECK(ps->subtitle_lines(2000) == std::vector<std::string>({"Bonjour"}));
|
|
CHECK_EQ(h.backend.subtitle_calls.load(), 2);
|
|
|
|
// Download failure: message, playback continues without subtitles.
|
|
h.press(InputEvent::Menu);
|
|
h.press(InputEvent::Down);
|
|
h.press(InputEvent::Ok);
|
|
h.press(InputEvent::Down, 3); // allemand (index 5)
|
|
h.press(InputEvent::Ok);
|
|
CHECK(run_until(h, [&] { return h.backend.subtitle_calls.load() == 3 && !ps->subtitles_loading(); }));
|
|
CHECK(!ps->subtitles_loaded(5));
|
|
CHECK(h.app->toast_text() == "Subtitles unavailable");
|
|
CHECK(ps->subtitle_lines(2000).empty());
|
|
CHECK(ps->session()->player().state() == player::PlayerState::Playing);
|
|
h.press(InputEvent::Back);
|
|
CHECK(run_until(h, [&] { return h.screen() == "detail"; }));
|
|
}
|
|
|
|
// Media without a track choice: Options opens nothing, a message says so.
|
|
static void test_player_options_without_tracks() {
|
|
Harness h;
|
|
h.backend.media_path = g_media_long;
|
|
open_detail(h);
|
|
h.press(InputEvent::Right);
|
|
h.press(InputEvent::Ok);
|
|
h.app->update(h.now = real_ms());
|
|
ui::PlayerScreen* ps = player_screen(h);
|
|
CHECK(ps != nullptr);
|
|
if (!ps) return;
|
|
CHECK(run_until(h, [&] { return ps->session()->phase() == ui::PlaybackSession::Phase::Playing; }));
|
|
h.press(InputEvent::Menu);
|
|
CHECK(!h.app->menu_open());
|
|
CHECK(!h.app->toast_text().empty());
|
|
h.press(InputEvent::Back);
|
|
CHECK(run_until(h, [&] { return h.screen() == "detail"; }));
|
|
}
|
|
|
|
int main() {
|
|
char dir[] = "/tmp/pelagia-uiplayer-XXXXXX";
|
|
if (!mkdtemp(dir)) return 1;
|
|
g_media_long = std::string(dir) + "/long.mp4";
|
|
g_media_short = std::string(dir) + "/short.mp4";
|
|
testmedia::SynthSpec spec;
|
|
spec.seconds = 20.0;
|
|
CHECK(testmedia::synth_media(g_media_long.c_str(), spec));
|
|
spec.seconds = 1.0;
|
|
CHECK(testmedia::synth_media(g_media_short.c_str(), spec));
|
|
|
|
test_play_pause_seek_stop();
|
|
test_resume_position();
|
|
test_end_of_media();
|
|
test_open_failure();
|
|
test_cancel_while_opening();
|
|
test_quit_while_playing();
|
|
test_destroy_while_stopping();
|
|
test_track_menu_in_player();
|
|
test_client_subtitles();
|
|
test_player_options_without_tracks();
|
|
|
|
const std::string cleanup = std::string("rm -rf ") + dir;
|
|
if (std::system(cleanup.c_str()) != 0) return 1;
|
|
return testfw::test_failures();
|
|
}
|