-#include <QMediaPlayer>
#include <QMainWindow>
#include <QApplication>
#include <QGridLayout>
#include <optional>
#include <sqlite3.h>
#include "mainwindow.h"
+#include "edit_player_dialog.h"
#include "ui_mainwindow.h"
#include "events.h"
#include "players.h"
return filename;
}
+bool get_match_property(sqlite3 *db, int match_id, const string &prop_name)
+{
+ sqlite3_stmt *stmt;
+
+ int ret = sqlite3_prepare_v2(db, ("SELECT " + prop_name + " FROM match WHERE match=?").c_str(), -1, &stmt, 0);
+ if (ret != SQLITE_OK) {
+ fprintf(stderr, "SELECT prepare: %s\n", sqlite3_errmsg(db));
+ abort();
+ }
+
+ sqlite3_bind_int64(stmt, 1, match_id);
+
+ ret = sqlite3_step(stmt);
+ if (ret != SQLITE_ROW) {
+ fprintf(stderr, "SELECT step: %s\n", sqlite3_errmsg(db));
+ abort();
+ }
+
+ if (sqlite3_column_type(stmt, 0) != SQLITE_INTEGER) {
+ return "";
+ }
+ bool value = sqlite3_column_int(stmt, 0);
+
+ ret = sqlite3_finalize(stmt);
+ if (ret != SQLITE_OK) {
+ fprintf(stderr, "SELECT finalize: %s\n", sqlite3_errmsg(db));
+ abort();
+ }
+ return value;
+}
+
void save_video_filename(sqlite3 *db, int match_id, const string &filename)
{
sqlite3_stmt *stmt;
ret = sqlite3_step(stmt);
if (ret == SQLITE_ROW) {
- fprintf(stderr, "INSERT step: %s\n", sqlite3_errmsg(db));
+ fprintf(stderr, "UPDATE step: %s\n", sqlite3_errmsg(db));
+ abort();
+ }
+
+ ret = sqlite3_finalize(stmt);
+ if (ret != SQLITE_OK) {
+ fprintf(stderr, "SELECT finalize: %s\n", sqlite3_errmsg(db));
+ abort();
+ }
+}
+
+void save_match_property(sqlite3 *db, int match_id, const string &prop_name, bool value)
+{
+ sqlite3_stmt *stmt;
+
+ int ret = sqlite3_prepare_v2(db, ("UPDATE match SET " + prop_name + "=? WHERE match=?").c_str(), -1, &stmt, 0);
+ if (ret != SQLITE_OK) {
+ fprintf(stderr, "SELECT prepare: %s\n", sqlite3_errmsg(db));
+ abort();
+ }
+
+ sqlite3_bind_int64(stmt, 1, value);
+ sqlite3_bind_int64(stmt, 2, match_id);
+
+ ret = sqlite3_step(stmt);
+ if (ret == SQLITE_ROW) {
+ fprintf(stderr, "UPDATE step: %s\n", sqlite3_errmsg(db));
abort();
}
// It's not really clear whether PgUp should be forwards or backwards,
// but mpv does at least up = forwards, so that's probably standard.
QShortcut *pgdown = new QShortcut(QKeySequence(Qt::Key_PageDown), this);
- connect(pgdown, &QShortcut::activated, [this]() { ui->video->seek(-120000); });
+ connect(pgdown, &QShortcut::activated, [this] { ui->video->seek(-120000); });
QShortcut *pgup = new QShortcut(QKeySequence(Qt::Key_PageUp), this);
- connect(pgup, &QShortcut::activated, [this]() { ui->video->seek(120000); });
+ connect(pgup, &QShortcut::activated, [this] { ui->video->seek(120000); });
- connect(ui->minus10s, &QPushButton::clicked, [this]() { ui->video->seek(-10000); });
- connect(ui->plus10s, &QPushButton::clicked, [this]() { ui->video->seek(10000); });
+ connect(ui->minus10s, &QPushButton::clicked, [this] { ui->video->seek(-10000); });
+ connect(ui->plus10s, &QPushButton::clicked, [this] { ui->video->seek(10000); });
- connect(ui->minus2s, &QPushButton::clicked, [this]() { ui->video->seek(-2000); });
- connect(ui->plus2s, &QPushButton::clicked, [this]() { ui->video->seek(2000); });
- connect(ui->video, &VideoWidget::mouse_back_clicked, [this]() { ui->video->seek(-2000); });
- connect(ui->video, &VideoWidget::mouse_forward_clicked, [this]() { ui->video->seek(2000); });
+ connect(ui->minus2s, &QPushButton::clicked, [this] { ui->video->seek(-2000); });
+ connect(ui->plus2s, &QPushButton::clicked, [this] { ui->video->seek(2000); });
+ connect(ui->video, &VideoWidget::mouse_back_clicked, [this] { ui->video->seek(-2000); });
+ connect(ui->video, &VideoWidget::mouse_forward_clicked, [this] { ui->video->seek(2000); });
- connect(ui->minus1f, &QPushButton::clicked, [this]() { ui->video->seek_frames(-1); });
- connect(ui->plus1f, &QPushButton::clicked, [this]() { ui->video->seek_frames(1); });
+ connect(ui->minus1f, &QPushButton::clicked, [this] { ui->video->seek_frames(-1); });
+ connect(ui->plus1f, &QPushButton::clicked, [this] { ui->video->seek_frames(1); });
- connect(ui->play_pause, &QPushButton::clicked, [this]() {
+ connect(ui->play_pause, &QPushButton::clicked, [this] {
if (playing) {
ui->video->pause();
ui->play_pause->setText("Play (space)");
ui->play_pause->setShortcut(QCoreApplication::translate("MainWindow", "Space", nullptr));
});
- connect(ui->player_1, &QPushButton::clicked, [this]() { insert_player_event(0); });
- connect(ui->player_2, &QPushButton::clicked, [this]() { insert_player_event(1); });
- connect(ui->player_3, &QPushButton::clicked, [this]() { insert_player_event(2); });
- connect(ui->player_4, &QPushButton::clicked, [this]() { insert_player_event(3); });
- connect(ui->player_5, &QPushButton::clicked, [this]() { insert_player_event(4); });
- connect(ui->player_6, &QPushButton::clicked, [this]() { insert_player_event(5); });
- connect(ui->player_7, &QPushButton::clicked, [this]() { insert_player_event(6); });
+ connect(ui->player_1, &QPushButton::clicked, [this] { insert_player_event(0); });
+ connect(ui->player_2, &QPushButton::clicked, [this] { insert_player_event(1); });
+ connect(ui->player_3, &QPushButton::clicked, [this] { insert_player_event(2); });
+ connect(ui->player_4, &QPushButton::clicked, [this] { insert_player_event(3); });
+ connect(ui->player_5, &QPushButton::clicked, [this] { insert_player_event(4); });
+ connect(ui->player_6, &QPushButton::clicked, [this] { insert_player_event(5); });
+ connect(ui->player_7, &QPushButton::clicked, [this] { insert_player_event(6); });
// Offensive events
- connect(ui->offense_label, &ClickableLabel::clicked, [this]() { insert_noplayer_event("set_offense"); });
- connect(ui->catch_, &QPushButton::clicked, [this]() { set_current_event_type("catch"); });
- connect(ui->throwaway, &QPushButton::clicked, [this, events]() {
+ connect(ui->offense_label, &ClickableLabel::clicked, [this] { insert_noplayer_event("set_offense"); });
+ connect(ui->catch_, &QPushButton::clicked, [this] { set_current_event_type("catch"); });
+ connect(ui->throwaway, &QPushButton::clicked, [this, events] {
EventsModel::Status s = events->get_status_at(ui->video->get_position());
if (s.attack_state == EventsModel::Status::DEFENSE && s.pull_state == EventsModel::Status::PULL_IN_AIR) {
insert_noplayer_event("pull_oob");
} else {
- set_current_event_type("throwaway");
+ insert_throwaway();
}
});
- connect(ui->drop, &QPushButton::clicked, [this]() { set_current_event_type("drop"); });
- connect(ui->goal, &QPushButton::clicked, [this]() { set_current_event_type("goal"); });
- connect(ui->stallout, &QPushButton::clicked, [this]() { set_current_event_type("stallout"); });
- connect(ui->soft_plus, &QPushButton::clicked, [this, events]() {
+ connect(ui->drop, &QPushButton::clicked, [this] { set_current_event_type("drop"); });
+ connect(ui->goal, &QPushButton::clicked, [this] { set_current_event_type("goal"); });
+ connect(ui->stallout, &QPushButton::clicked, [this] { set_current_event_type("stallout"); });
+ connect(ui->soft_plus, &QPushButton::clicked, [this, events] {
EventsModel::Status s = events->get_status_at(ui->video->get_position());
if (s.attack_state == EventsModel::Status::OFFENSE) {
set_current_event_type("offensive_soft_plus");
set_current_event_type("defensive_soft_plus");
}
});
- connect(ui->soft_minus, &QPushButton::clicked, [this, events]() {
+ connect(ui->soft_minus, &QPushButton::clicked, [this, events] {
EventsModel::Status s = events->get_status_at(ui->video->get_position());
if (s.attack_state == EventsModel::Status::OFFENSE) {
set_current_event_type("offensive_soft_minus");
set_current_event_type("defensive_soft_minus");
}
});
- connect(ui->pull_or_was_d, &QPushButton::clicked, [this, events]() {
+ connect(ui->pull_or_was_d, &QPushButton::clicked, [this, events] {
EventsModel::Status s = events->get_status_at(ui->video->get_position());
if (s.pull_state == EventsModel::Status::SHOULD_PULL ||
events->get_status_at(ui->video->get_position() - 1).pull_state == EventsModel::Status::SHOULD_PULL) {
}
});
- // Defensive events (TODO add more)
- connect(ui->interception, &QPushButton::clicked, [this]() { set_current_event_type("interception"); });
- connect(ui->defense_label, &ClickableLabel::clicked, [this]() { insert_noplayer_event("set_defense"); });
- connect(ui->their_throwaway, &QPushButton::clicked, [this]() { insert_noplayer_event("their_throwaway"); });
- connect(ui->their_goal, &QPushButton::clicked, [this]() { insert_noplayer_event("their_goal"); });
- connect(ui->their_pull, &QPushButton::clicked, [this, events]() {
+ // Defensive events.
+ connect(ui->interception, &QPushButton::clicked, [this] { set_current_event_type("interception"); });
+ connect(ui->defense_label, &ClickableLabel::clicked, [this] { insert_noplayer_event("set_defense"); });
+ connect(ui->their_throwaway, &QPushButton::clicked, [this] { insert_noplayer_event("their_throwaway"); });
+ connect(ui->their_goal, &QPushButton::clicked, [this] { insert_noplayer_event("their_goal"); });
+ connect(ui->their_pull, &QPushButton::clicked, [this, events] {
EventsModel::Status s = events->get_status_at(ui->video->get_position());
if (s.pull_state == EventsModel::Status::SHOULD_PULL) {
insert_noplayer_event("their_pull");
}
});
- connect(ui->our_defense, &QPushButton::clicked, [this]() { set_current_event_type("defense"); });
+ connect(ui->our_defense, &QPushButton::clicked, [this] { set_current_event_type("defense"); });
- connect(ui->offensive_formation, &QPushButton::clicked, [this]() { insert_or_change_formation(/*offense=*/true); });
- connect(ui->defensive_formation, &QPushButton::clicked, [this]() { insert_or_change_formation(/*offense=*/false); });
+ connect(ui->offensive_formation, &QPushButton::clicked, [this] { insert_or_change_formation(/*offense=*/true); });
+ connect(ui->defensive_formation, &QPushButton::clicked, [this] { insert_or_change_formation(/*offense=*/false); });
// Misc. events
- connect(ui->substitution, &QPushButton::clicked, [this]() { make_substitution(); });
- connect(ui->stoppage, &QPushButton::clicked, [this, events]() {
+ connect(ui->substitution, &QPushButton::clicked, [this] { make_substitution(); });
+ connect(ui->stoppage, &QPushButton::clicked, [this, events] {
EventsModel::Status s = events->get_status_at(ui->video->get_position());
if (s.stoppage) {
insert_noplayer_event("restart");
insert_noplayer_event("stoppage");
}
});
- connect(ui->unknown, &QPushButton::clicked, [this]() { insert_noplayer_event("unknown"); });
+ connect(ui->unknown, &QPushButton::clicked, [this] { insert_noplayer_event("unknown"); });
QShortcut *key_delete = new QShortcut(QKeySequence(Qt::Key_Delete), this);
- connect(key_delete, &QShortcut::activated, [this]() { ui->delete_->animateClick(); });
- connect(ui->delete_, &QPushButton::clicked, [this]() { delete_current_event(); });
+ connect(key_delete, &QShortcut::activated, [this] { ui->delete_->animateClick(); });
+ connect(ui->delete_, &QPushButton::clicked, [this] { delete_current_event(); });
+
+ // Player list shortcuts.
+ connect(ui->get_current_players, &QPushButton::clicked, [this, players, events] {
+ uint64_t t = ui->video->get_position();
+ QItemSelection selection;
+ set<int> team = events->get_team_at(t);
+ for (int row = 0; row < players->rowCount(QModelIndex()); ++row) {
+ if (team.count(players->get_player_id(row))) {
+ selection.select(players->get_row_start_qt(row), players->get_row_end_qt(row));
+ }
+ }
+ ui->player_view->selectionModel()->select(selection, QItemSelectionModel::ClearAndSelect);
+ });
+ connect(ui->clear_player_list, &QPushButton::clicked, [this] {
+ ui->player_view->selectionModel()->clear();
+ });
+ connect(ui->player_view, &QTableView::doubleClicked, [this](const QModelIndex &index) {
+ open_edit_player_dialog(index.row());
+ });
+ connect(ui->player_view->selectionModel(), &QItemSelectionModel::selectionChanged, [this] {
+ update_gender_ratio(ui->video->get_position());
+ });
+ update_gender_ratio(0);
+
+ // The shortcuts take up so much space that we really need, so we sacrifice the header.
+ ui->player_view->horizontalHeader()->hide();
+
+ // Menus.
+ connect(ui->action_exit, &QAction::triggered, [this] { close(); });
+ connect(ui->action_export_json, &QAction::triggered, [db] { export_to_json(db, "ultimate.json"); });
+
+ ui->action_gender_rule_a->setChecked(get_match_property(db, match_id, "gender_rule_a"));
+ ui->action_gender_pull_rule->setChecked(get_match_property(db, match_id, "gender_pull_rule"));
+ connect(ui->action_gender_rule_a, &QAction::toggled, [this, db, match_id] {
+ save_match_property(db, match_id, "gender_rule_a", ui->action_gender_rule_a->isChecked());
+ });
+ connect(ui->action_gender_pull_rule, &QAction::toggled, [this, db, match_id] {
+ save_match_property(db, match_id, "gender_pull_rule", ui->action_gender_pull_rule->isChecked());
+ update_gender_ratio(ui->video->get_position());
+ });
}
void MainWindow::position_changed(uint64_t pos)
update_ui_from_time(ui->video->get_position());
}
+void MainWindow::insert_throwaway()
+{
+ uint64_t t = ui->video->get_position();
+
+ QItemSelectionModel *select = ui->event_view->selectionModel();
+ if (select->hasSelection()) {
+ int row = select->selectedRows().front().row(); // Should only be one, due to our selection behavior.
+ // We could want to modify this catch event into a throwaway. See if that is the case.
+ int last_catching_player = events->get_status_at(events->get_time(row) - 1).last_catching_player;
+ if (last_catching_player != -1 && last_catching_player == events->get_player_id(row)) {
+ // Last event was that this player caught the disc, so yes, make this a throwaway.
+ events->set_event_type(row, "throwaway");
+ update_ui_from_time(t);
+ return;
+ }
+ // It doesn't make sense that the player throws it away without holding the disc first,
+ // so we insert a new event where the person holding the disc throws it away.
+ // (In other words, fall back to inserting a new one based on time.)
+ }
+
+ int last_catching_player = events->get_status_at(t - 1).last_catching_player;
+ if (last_catching_player == -1) {
+ return;
+ }
+
+ ui->event_view->selectionModel()->blockSignals(true);
+ ui->event_view->selectRow(events->insert_event(t, last_catching_player, nullopt, "throwaway"));
+ ui->event_view->selectionModel()->blockSignals(false);
+}
+
// Formation buttons either modify the existing formation (if we've selected
// a formation change event), or insert a new one (if not).
void MainWindow::insert_or_change_formation(bool offense)
update_status(t);
update_player_buttons(t);
update_action_buttons(t);
+ update_gender_ratio(t);
}
void MainWindow::update_status(uint64_t t)
snprintf(buf, sizeof(buf), "%d–%d | %s | %d passes, %d sec possession",
s.our_score, s.their_score, formation.c_str(), s.num_passes, s.possession_sec);
if (s.stoppage_sec > 0) {
- char buf2[256];
+ char buf2[512];
snprintf(buf2, sizeof(buf2), "%s (plus %d sec stoppage)", buf, s.stoppage_sec);
ui->status->setText(buf2);
} else {
ui->pull_or_was_d->setEnabled(true);
ui->catch_->setEnabled(s.attack_state == EventsModel::Status::OFFENSE && has_selection_with_player);
- ui->throwaway->setEnabled(s.attack_state == EventsModel::Status::OFFENSE && has_selection_with_player);
+ ui->throwaway->setEnabled(s.attack_state == EventsModel::Status::OFFENSE && s.last_catching_player != -1);
ui->drop->setEnabled(s.attack_state == EventsModel::Status::OFFENSE && has_selection_with_player);
ui->goal->setEnabled(s.attack_state == EventsModel::Status::OFFENSE && has_selection_with_player);
ui->stallout->setEnabled(s.attack_state == EventsModel::Status::OFFENSE && has_selection_with_player);
ui->their_pull->setEnabled(false);
}
+vector<pair<string, int>> sort_gender(const map<string, int> &gender_count)
+{
+ vector<pair<string, int>> sorted_gender;
+ for (const auto &[gender, count] : gender_count) {
+ sorted_gender.emplace_back(gender, count);
+ }
+ sort(sorted_gender.begin(), sorted_gender.end(), [](const pair<string, int> &a, const pair<string, int> &b) {
+ if (a.second != b.second) {
+ return b.second < a.second;
+ }
+ return a.first < b.first;
+ });
+ return sorted_gender;
+}
+
+string format_gender_counts(const map<string, int> &gender_count)
+{
+ vector<pair<string, int>> sorted_gender = sort_gender(gender_count);
+
+ string str;
+ for (const auto &[gender, count] : sorted_gender) {
+ if (!str.empty()) {
+ str += ", ";
+ }
+ char buf[256];
+ snprintf(buf, sizeof(buf), "%d ", count);
+ str += buf;
+ str += gender;
+ }
+ return str;
+}
+
+string format_gender_counts(const map<string, int> &gender_count, const map<string, int> &reference_gender_count)
+{
+ vector<pair<string, int>> sorted_gender = sort_gender(reference_gender_count); // Less swapping around this way.
+
+ string str;
+ for (const auto &[gender, count] : sorted_gender) {
+ if (!str.empty()) {
+ str += ", ";
+ }
+ char buf[256];
+ snprintf(buf, sizeof(buf), "%d/%d ", gender_count.find(gender)->second, count);
+ str += buf;
+ str += gender;
+ }
+ return str;
+}
+
+void MainWindow::update_gender_ratio(uint64_t t)
+{
+ string str;
+ bool ok = true;
+
+ // Count the gender ratio in the current selection.
+ map<string, int> gender_count;
+ for (int i = 0; i < players->rowCount(QModelIndex()); ++i) {
+ string gender = players->get_player_gender(i);
+ gender_count[gender] = 0;
+ }
+ int num_players = 0;
+ QItemSelectionModel *select = ui->player_view->selectionModel();
+ for (QModelIndex row : select->selectedRows()) {
+ string gender = players->get_player_gender(row.row());
+ ++gender_count[gender];
+ ++num_players;
+ }
+
+ const bool gender_rule_a = ui->action_gender_rule_a->isChecked();
+ if (gender_rule_a) {
+ // This is tricky. We don't want to hard-code assumptions about gender,
+ // since there are so many possible variations (e.g. 5 players for indoors,
+ // or loose mixed). We can't get everything right, but our general
+ // strategy will be:
+ //
+ // - We assume ABBA pattern is followed throughout, ie., we switch
+ // on odd-numbered points. We use goals as reference for points.
+ // - We always use the two or three latest points as reference;
+ // this means an issue with the first point won't persist forever.
+ // - When we don't switch, we expect the identical number as last time.
+ // - When we _do_ switch, we copy the variation from two points ago
+ // if we have it; if not, we simply expect it to be different.
+ // - We should always have the same number of people as last point.
+ //
+ // The viewer's warnings also checks that things are correct across
+ // stoppages, which we don't yet.
+ map<string, int> current_gender_count;
+ for (int i = 0; i < players->rowCount(QModelIndex()); ++i) {
+ string gender = players->get_player_gender(i);
+ current_gender_count[gender] = 0;
+ }
+ vector<map<string, int>> historical_gender_counts;
+ for (int row = 0; row < events->rowCount(QModelIndex()); ++row) {
+ if (events->get_time(row) > t) {
+ break;
+ }
+ EventType type = events->get_event_type(row);
+ if (type == EventType::GOAL || type == EventType::THEIR_GOAL) {
+ historical_gender_counts.push_back(current_gender_count);
+ } else if (type == EventType::SWAP_IN) {
+ string gender = players->get_player_gender_by_id(*events->get_player_id(row));
+ ++current_gender_count[gender];
+ } else if (type == EventType::SWAP_OUT) {
+ string gender = players->get_player_gender_by_id(*events->get_player_id(row));
+ --current_gender_count[gender];
+ }
+ }
+
+ ok = true;
+ if (historical_gender_counts.empty()) {
+ // We don't have any points yet. Just output the ratio.
+ str = format_gender_counts(gender_count);
+ } else if (historical_gender_counts.size() == 1) {
+ // We have one, so this one must be different, but we don't know what it must be.
+ // It must have the same number of players, though.
+ str = format_gender_counts(gender_count);
+ ok = (gender_count != historical_gender_counts.back());
+ int old_sum = 0, new_sum = 0;
+ for (const auto &[gender, count] : historical_gender_counts.back()) {
+ old_sum += count;
+ }
+ for (const auto &[gender, count] : gender_count) {
+ new_sum += count;
+ }
+ if (old_sum != new_sum) {
+ ok = false;
+ }
+ } else if (historical_gender_counts.size() % 2 == 0) {
+ // Must be same as previous.
+ str = format_gender_counts(gender_count, historical_gender_counts.back());
+ ok = (gender_count == historical_gender_counts.back());
+ } else {
+ // Must be same as two points ago.
+ const auto &ref = historical_gender_counts[historical_gender_counts.size() - 3];
+ str = format_gender_counts(gender_count, ref);
+ ok = (gender_count == ref);
+ }
+ } else if (gender_count.size() == 1) {
+ // Everybody is either of the same gender or nobody has gender noted,
+ // so just count the number of players. We don't make red here.
+ char buf[256];
+ snprintf(buf, sizeof(buf), "%d selected", num_players);
+ str = buf;
+ } else {
+ // We don't have gender rule A, but we have gender counts,
+ // so show that. We don't make red here.
+ string str = format_gender_counts(gender_count);
+ }
+
+ // Seemingly this setting this every frame is very costly, so we diff.
+ if (QString::fromUtf8(str) != ui->selected_gender_ratio->text()) {
+ ui->selected_gender_ratio->setText(QString::fromUtf8(str));
+ }
+ bool current_ok = ui->selected_gender_ratio->styleSheet().isEmpty();
+ if (ok && !current_ok) {
+ ui->selected_gender_ratio->setStyleSheet("");
+ } else if (!ok && current_ok) {
+ ui->selected_gender_ratio->setStyleSheet("QLabel { color: red }");
+ }
+}
+
void MainWindow::formation_double_clicked(bool offense, unsigned row)
{
FormationsModel *formations = offense ? offensive_formations : defensive_formations;
update_ui_from_time(ui->video->get_position());
}
+void MainWindow::open_edit_player_dialog(unsigned row)
+{
+ int player_id = players->get_player_id(row);
+ string number = players->get_player_number(row);
+ string gender = players->get_player_gender(row);
+ string name = players->get_player_name(row);
+
+ EditPlayerDialog *dialog = new EditPlayerDialog(number, gender, name);
+ connect(dialog, &QDialog::finished, [this, dialog, player_id](int result) {
+ if (result == QDialog::Accepted) {
+ players->edit_player(player_id, dialog->number(), dialog->gender(), dialog->name());
+ update_ui_from_time(ui->video->get_position());
+ }
+ });
+ dialog->show();
+}