diff --git a/dist/qt_themes/default/default.qrc b/dist/qt_themes/default/default.qrc
index a3e21645a..a524a17e7 100644
--- a/dist/qt_themes/default/default.qrc
+++ b/dist/qt_themes/default/default.qrc
@@ -5,7 +5,13 @@
icons/16x16/checked.png
icons/16x16/failed.png
-
+
+ icons/16x16/connected.png
+
+ icons/16x16/disconnected.png
+
+ icons/16x16/lock.png
+
icons/256x256/citra.png
diff --git a/dist/qt_themes/default/icons/16x16/connected.png b/dist/qt_themes/default/icons/16x16/connected.png
new file mode 100644
index 000000000..afa797394
Binary files /dev/null and b/dist/qt_themes/default/icons/16x16/connected.png differ
diff --git a/dist/qt_themes/default/icons/16x16/disconnected.png b/dist/qt_themes/default/icons/16x16/disconnected.png
new file mode 100644
index 000000000..835b1f0d6
Binary files /dev/null and b/dist/qt_themes/default/icons/16x16/disconnected.png differ
diff --git a/dist/qt_themes/default/icons/16x16/lock.png b/dist/qt_themes/default/icons/16x16/lock.png
new file mode 100644
index 000000000..496b58078
Binary files /dev/null and b/dist/qt_themes/default/icons/16x16/lock.png differ
diff --git a/dist/qt_themes/qdarkstyle/icons/16x16/lock.png b/dist/qt_themes/qdarkstyle/icons/16x16/lock.png
new file mode 100644
index 000000000..c750a39e8
Binary files /dev/null and b/dist/qt_themes/qdarkstyle/icons/16x16/lock.png differ
diff --git a/dist/qt_themes/qdarkstyle/style.qrc b/dist/qt_themes/qdarkstyle/style.qrc
index efbd0b9dc..54a96b680 100644
--- a/dist/qt_themes/qdarkstyle/style.qrc
+++ b/dist/qt_themes/qdarkstyle/style.qrc
@@ -1,6 +1,7 @@
icons/index.theme
+ icons/16x16/lock.png
rc/up_arrow_disabled.png
diff --git a/src/citra_qt/CMakeLists.txt b/src/citra_qt/CMakeLists.txt
index 4d08a622e..1a150065f 100644
--- a/src/citra_qt/CMakeLists.txt
+++ b/src/citra_qt/CMakeLists.txt
@@ -56,11 +56,29 @@ add_executable(citra-qt
hotkeys.h
main.cpp
main.h
+ multiplayer/chat_room.h
+ multiplayer/chat_room.cpp
+ multiplayer/client_room.h
+ multiplayer/client_room.cpp
+ multiplayer/direct_connect.h
+ multiplayer/direct_connect.cpp
+ multiplayer/host_room.h
+ multiplayer/host_room.cpp
+ multiplayer/lobby.h
+ multiplayer/lobby_p.h
+ multiplayer/lobby.cpp
+ multiplayer/message.h
+ multiplayer/message.cpp
+ multiplayer/state.cpp
+ multiplayer/state.h
+ multiplayer/validation.h
ui_settings.cpp
ui_settings.h
updater/updater.cpp
updater/updater.h
updater/updater_p.h
+ util/clickable_label.h
+ util/clickable_label.cpp
util/spinbox.cpp
util/spinbox.h
util/util.cpp
@@ -79,6 +97,11 @@ set(UIS
configuration/configure_system.ui
configuration/configure_web.ui
debugger/registers.ui
+ multiplayer/direct_connect.ui
+ multiplayer/lobby.ui
+ multiplayer/chat_room.ui
+ multiplayer/client_room.ui
+ multiplayer/host_room.ui
aboutdialog.ui
hotkeys.ui
main.ui
diff --git a/src/citra_qt/bootmanager.cpp b/src/citra_qt/bootmanager.cpp
index 1691a5124..dc345ad57 100644
--- a/src/citra_qt/bootmanager.cpp
+++ b/src/citra_qt/bootmanager.cpp
@@ -108,12 +108,10 @@ GRenderWindow::GRenderWindow(QWidget* parent, EmuThread* emu_thread)
setWindowTitle(QString::fromStdString(window_title));
InputCommon::Init();
- Network::Init();
}
GRenderWindow::~GRenderWindow() {
InputCommon::Shutdown();
- Network::Shutdown();
}
void GRenderWindow::moveContext() {
diff --git a/src/citra_qt/configuration/config.cpp b/src/citra_qt/configuration/config.cpp
index a0dc0cd84..868447a4a 100644
--- a/src/citra_qt/configuration/config.cpp
+++ b/src/citra_qt/configuration/config.cpp
@@ -7,6 +7,7 @@
#include "citra_qt/ui_settings.h"
#include "common/file_util.h"
#include "input_common/main.h"
+#include "network/network.h"
Config::Config() {
// TODO: Don't hardcode the path; let the frontend decide where to put the config files.
@@ -162,6 +163,12 @@ void Config::ReadValues() {
qt_config->value("verify_endpoint_url", "https://services.citra-emu.org/api/profile")
.toString()
.toStdString();
+ Settings::values.announce_multiplayer_room_endpoint_url =
+ qt_config
+ ->value("announce_multiplayer_room_endpoint_url",
+ "https://services.citra-emu.org/api/multiplayer/rooms")
+ .toString()
+ .toStdString();
Settings::values.citra_username = qt_config->value("citra_username").toString().toStdString();
Settings::values.citra_token = qt_config->value("citra_token").toString().toStdString();
qt_config->endGroup();
@@ -225,6 +232,22 @@ void Config::ReadValues() {
UISettings::values.first_start = qt_config->value("firstStart", true).toBool();
UISettings::values.callout_flags = qt_config->value("calloutFlags", 0).toUInt();
+ qt_config->beginGroup("Multiplayer");
+ UISettings::values.nickname = qt_config->value("nickname", "").toString();
+ UISettings::values.ip = qt_config->value("ip", "").toString();
+ UISettings::values.port = qt_config->value("port", Network::DefaultRoomPort).toString();
+ UISettings::values.room_nickname = qt_config->value("room_nickname", "").toString();
+ UISettings::values.room_name = qt_config->value("room_name", "").toString();
+ UISettings::values.room_port = qt_config->value("room_port", "24872").toString();
+ bool ok;
+ UISettings::values.host_type = qt_config->value("host_type", 0).toUInt(&ok);
+ if (!ok) {
+ UISettings::values.host_type = 0;
+ }
+ UISettings::values.max_player = qt_config->value("max_player", 8).toUInt();
+ UISettings::values.game_id = qt_config->value("game_id", 0).toULongLong();
+ qt_config->endGroup();
+
qt_config->endGroup();
}
@@ -320,6 +343,9 @@ void Config::SaveValues() {
QString::fromStdString(Settings::values.telemetry_endpoint_url));
qt_config->setValue("verify_endpoint_url",
QString::fromStdString(Settings::values.verify_endpoint_url));
+ qt_config->setValue(
+ "announce_multiplayer_room_endpoint_url",
+ QString::fromStdString(Settings::values.announce_multiplayer_room_endpoint_url));
qt_config->setValue("citra_username", QString::fromStdString(Settings::values.citra_username));
qt_config->setValue("citra_token", QString::fromStdString(Settings::values.citra_token));
qt_config->endGroup();
@@ -366,6 +392,18 @@ void Config::SaveValues() {
qt_config->setValue("firstStart", UISettings::values.first_start);
qt_config->setValue("calloutFlags", UISettings::values.callout_flags);
+ qt_config->beginGroup("Multiplayer");
+ qt_config->setValue("nickname", UISettings::values.nickname);
+ qt_config->setValue("ip", UISettings::values.ip);
+ qt_config->setValue("port", UISettings::values.port);
+ qt_config->setValue("room_nickname", UISettings::values.room_nickname);
+ qt_config->setValue("room_name", UISettings::values.room_name);
+ qt_config->setValue("room_port", UISettings::values.room_port);
+ qt_config->setValue("host_type", UISettings::values.host_type);
+ qt_config->setValue("max_player", UISettings::values.max_player);
+ qt_config->setValue("game_id", UISettings::values.game_id);
+ qt_config->endGroup();
+
qt_config->endGroup();
}
diff --git a/src/citra_qt/game_list.cpp b/src/citra_qt/game_list.cpp
index 401cd9d98..1bd387b75 100644
--- a/src/citra_qt/game_list.cpp
+++ b/src/citra_qt/game_list.cpp
@@ -374,6 +374,10 @@ void GameList::LoadCompatibilityList() {
}
}
+QStandardItemModel* GameList::GetModel() const {
+ return item_model;
+}
+
void GameList::PopulateAsync(const QString& dir_path, bool deep_scan) {
if (!FileUtil::Exists(dir_path.toStdString()) ||
!FileUtil::IsDirectory(dir_path.toStdString())) {
diff --git a/src/citra_qt/game_list.h b/src/citra_qt/game_list.h
index c01dc1d21..376dc8474 100644
--- a/src/citra_qt/game_list.h
+++ b/src/citra_qt/game_list.h
@@ -76,6 +76,8 @@ public:
void SaveInterfaceLayout();
void LoadInterfaceLayout();
+ QStandardItemModel* GetModel() const;
+
static const QStringList supported_file_extensions;
signals:
diff --git a/src/citra_qt/main.cpp b/src/citra_qt/main.cpp
index 6709874d5..519d1e2e5 100644
--- a/src/citra_qt/main.cpp
+++ b/src/citra_qt/main.cpp
@@ -31,8 +31,10 @@
#include "citra_qt/game_list.h"
#include "citra_qt/hotkeys.h"
#include "citra_qt/main.h"
+#include "citra_qt/multiplayer/state.h"
#include "citra_qt/ui_settings.h"
#include "citra_qt/updater/updater.h"
+#include "citra_qt/util/clickable_label.h"
#include "common/logging/backend.h"
#include "common/logging/filter.h"
#include "common/logging/log.h"
@@ -115,6 +117,8 @@ GMainWindow::GMainWindow() : config(new Config()), emu_thread(nullptr) {
default_theme_paths = QIcon::themeSearchPaths();
UpdateUITheme();
+ Network::Init();
+
InitializeWidgets();
InitializeDebugWidgets();
InitializeRecentFileMenuActions();
@@ -153,6 +157,7 @@ GMainWindow::~GMainWindow() {
delete render_window;
Pica::g_debug_context.reset();
+ Network::Shutdown();
}
void GMainWindow::InitializeWidgets() {
@@ -165,6 +170,10 @@ void GMainWindow::InitializeWidgets() {
game_list = new GameList(this);
ui.horizontalLayout->addWidget(game_list);
+ multiplayer_state = new MultiplayerState(this, game_list->GetModel(), ui.action_Leave_Room,
+ ui.action_Show_Room);
+ multiplayer_state->setVisible(false);
+
// Setup updater
updater = new Updater(this);
UISettings::values.updater_found = updater->HasUpdater();
@@ -199,6 +208,8 @@ void GMainWindow::InitializeWidgets() {
label->setContentsMargins(4, 0, 4, 0);
statusBar()->addPermanentWidget(label, 0);
}
+ statusBar()->addPermanentWidget(multiplayer_state->GetStatusText(), 0);
+ statusBar()->addPermanentWidget(multiplayer_state->GetStatusIcon(), 0);
statusBar()->setVisible(true);
// Removes an ugly inner border from the status bar widgets under Linux
@@ -418,6 +429,19 @@ void GMainWindow::ConnectMenuEvents() {
ui.action_Show_Filter_Bar->setShortcut(tr("CTRL+F"));
connect(ui.action_Show_Filter_Bar, &QAction::triggered, this, &GMainWindow::OnToggleFilterBar);
connect(ui.action_Show_Status_Bar, &QAction::triggered, statusBar(), &QStatusBar::setVisible);
+
+ // Multiplayer
+ connect(ui.action_View_Lobby, &QAction::triggered, multiplayer_state,
+ &MultiplayerState::OnViewLobby);
+ connect(ui.action_Start_Room, &QAction::triggered, multiplayer_state,
+ &MultiplayerState::OnCreateRoom);
+ connect(ui.action_Leave_Room, &QAction::triggered, multiplayer_state,
+ &MultiplayerState::OnCloseRoom);
+ connect(ui.action_Connect_To_Room, &QAction::triggered, multiplayer_state,
+ &MultiplayerState::OnDirectConnectToRoom);
+ connect(ui.action_Show_Room, &QAction::triggered, multiplayer_state,
+ &MultiplayerState::OnOpenNetworkRoom);
+
ui.action_Fullscreen->setShortcut(GetHotkey("Main Window", "Fullscreen", this)->key());
ui.action_Screen_Layout_Swap_Screens->setShortcut(
GetHotkey("Main Window", "Swap Screens", this)->key());
@@ -1186,7 +1210,7 @@ void GMainWindow::closeEvent(QCloseEvent* event) {
ShutdownGame();
render_window->close();
-
+ multiplayer_state->Close();
QWidget::closeEvent(event);
}
diff --git a/src/citra_qt/main.h b/src/citra_qt/main.h
index c29c0ccfc..686624603 100644
--- a/src/citra_qt/main.h
+++ b/src/citra_qt/main.h
@@ -5,15 +5,18 @@
#pragma once
#include
+#include
#include
#include
#include
+#include "common/announce_multiplayer_room.h"
#include "core/core.h"
#include "core/hle/service/am/am.h"
#include "ui_main.h"
class AboutDialog;
class Config;
+class ClickableLabel;
class EmuThread;
class GameList;
enum class GameListOpenTarget;
@@ -25,6 +28,7 @@ class GraphicsTracingWidget;
class GraphicsVertexShaderWidget;
class GRenderWindow;
class MicroProfileDialog;
+class MultiplayerState;
class ProfilerWidget;
template
class QFutureWatcher;
@@ -50,9 +54,12 @@ class GMainWindow : public QMainWindow {
public:
void filterBarSetChecked(bool state);
void UpdateUITheme();
+
GMainWindow();
~GMainWindow();
+ GameList* game_list;
+
signals:
/**
@@ -173,7 +180,6 @@ private:
Ui::MainWindow ui;
GRenderWindow* render_window;
- GameList* game_list;
// Status bar elements
QProgressBar* progress_bar = nullptr;
@@ -183,6 +189,7 @@ private:
QLabel* emu_frametime_label = nullptr;
QTimer status_bar_update_timer;
+ MultiplayerState* multiplayer_state = nullptr;
std::unique_ptr config;
// Whether emulation is currently running in Citra.
diff --git a/src/citra_qt/main.ui b/src/citra_qt/main.ui
index c598c444f..86c4e46ed 100644
--- a/src/citra_qt/main.ui
+++ b/src/citra_qt/main.ui
@@ -45,7 +45,7 @@
0
0
1081
- 26
+ 21
+
@@ -228,6 +243,43 @@
Create Pica Surface Viewer
+
+
+ true
+
+
+ Browse Public Game Lobby
+
+
+
+
+ true
+
+
+ Create Room
+
+
+
+
+ false
+
+
+ Leave Room
+
+
+
+
+ Direct Connect to Room
+
+
+
+
+ false
+
+
+ Show Current Room
+
+
true
@@ -244,46 +296,46 @@
Opens the maintenance tool to modify your Citra installation
-
-
- true
-
-
- Default
-
-
-
-
- true
-
-
- Single Screen
-
-
-
-
- true
-
-
- Large Screen
-
-
-
-
- true
-
-
- Side by Side
-
-
-
-
- true
-
-
- Swap Screens
-
-
+
+
+ true
+
+
+ Default
+
+
+
+
+ true
+
+
+ Single Screen
+
+
+
+
+ true
+
+
+ Large Screen
+
+
+
+
+ true
+
+
+ Side by Side
+
+
+
+
+ true
+
+
+ Swap Screens
+
+
Check for Updates
diff --git a/src/citra_qt/multiplayer/chat_room.cpp b/src/citra_qt/multiplayer/chat_room.cpp
new file mode 100644
index 000000000..5bb20bc12
--- /dev/null
+++ b/src/citra_qt/multiplayer/chat_room.cpp
@@ -0,0 +1,211 @@
+// Copyright 2017 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include "citra_qt/game_list_p.h"
+#include "citra_qt/multiplayer/chat_room.h"
+#include "citra_qt/multiplayer/message.h"
+#include "common/logging/log.h"
+#include "core/announce_multiplayer_session.h"
+#include "ui_chat_room.h"
+
+class ChatMessage {
+public:
+ explicit ChatMessage(const Network::ChatEntry& chat, QTime ts = {}) {
+ /// Convert the time to their default locale defined format
+ QLocale locale;
+ timestamp = locale.toString(ts.isValid() ? ts : QTime::currentTime(), QLocale::ShortFormat);
+ nickname = QString::fromStdString(chat.nickname);
+ message = QString::fromStdString(chat.message);
+ }
+
+ /// Format the message using the players color
+ QString GetPlayerChatMessage(u16 player) const {
+ auto color = player_color[player % 16];
+ return QString("[%1] <%3> %4")
+ .arg(timestamp, color, nickname.toHtmlEscaped(), message.toHtmlEscaped());
+ }
+
+private:
+ static constexpr std::array player_color = {
+ {"#0000FF", "#FF0000", "#8A2BE2", "#FF69B4", "#1E90FF", "#008000", "#00FF7F", "#B22222",
+ "#DAA520", "#FF4500", "#2E8B57", "#5F9EA0", "#D2691E", "#9ACD32", "#FF7F50", "FFFF00"}};
+
+ QString timestamp;
+ QString nickname;
+ QString message;
+};
+
+class StatusMessage {
+public:
+ explicit StatusMessage(const QString& msg, QTime ts = {}) {
+ /// Convert the time to their default locale defined format
+ QLocale locale;
+ timestamp = locale.toString(ts.isValid() ? ts : QTime::currentTime(), QLocale::ShortFormat);
+ message = msg;
+ }
+
+ QString GetSystemChatMessage() const {
+ return QString("[%1] %3")
+ .arg(timestamp, system_color, message);
+ }
+
+private:
+ static constexpr const char system_color[] = "#888888";
+ QString timestamp;
+ QString message;
+};
+
+ChatRoom::ChatRoom(QWidget* parent) : QWidget(parent), ui(std::make_unique()) {
+ ui->setupUi(this);
+
+ // set the item_model for player_view
+ enum {
+ COLUMN_NAME,
+ COLUMN_GAME,
+ COLUMN_COUNT, // Number of columns
+ };
+
+ player_list = new QStandardItemModel(ui->player_view);
+ ui->player_view->setModel(player_list);
+ player_list->insertColumns(0, COLUMN_COUNT);
+ player_list->setHeaderData(COLUMN_NAME, Qt::Horizontal, tr("Name"));
+ player_list->setHeaderData(COLUMN_GAME, Qt::Horizontal, tr("Game"));
+
+ ui->chat_history->document()->setMaximumBlockCount(max_chat_lines);
+
+ // register the network structs to use in slots and signals
+ qRegisterMetaType();
+ qRegisterMetaType();
+ qRegisterMetaType();
+
+ // setup the callbacks for network updates
+ if (auto member = Network::GetRoomMember().lock()) {
+ member->BindOnChatMessageRecieved(
+ [this](const Network::ChatEntry& chat) { emit ChatReceived(chat); });
+ connect(this, &ChatRoom::ChatReceived, this, &ChatRoom::OnChatReceive);
+ } else {
+ // TODO (jroweboy) network was not initialized?
+ }
+
+ // Connect all the widgets to the appropriate events
+ connect(ui->chat_message, &QLineEdit::returnPressed, ui->send_message, &QPushButton::pressed);
+ connect(ui->chat_message, &QLineEdit::textChanged, this, &::ChatRoom::OnChatTextChanged);
+ connect(ui->send_message, &QPushButton::pressed, this, &ChatRoom::OnSendChat);
+}
+
+ChatRoom::~ChatRoom() = default;
+
+void ChatRoom::Clear() {
+ ui->chat_history->clear();
+}
+
+void ChatRoom::AppendStatusMessage(const QString& msg) {
+ ui->chat_history->append(StatusMessage(msg).GetSystemChatMessage());
+}
+
+void ChatRoom::AppendChatMessage(const QString& msg) {
+ ui->chat_history->append(msg);
+}
+
+bool ChatRoom::ValidateMessage(const std::string& msg) {
+ return !msg.empty();
+}
+
+void ChatRoom::OnRoomUpdate(const Network::RoomInformation& info) {
+ // TODO(B3N30): change title
+ if (auto room_member = Network::GetRoomMember().lock()) {
+ SetPlayerList(room_member->GetMemberInformation());
+ }
+}
+
+void ChatRoom::Disable() {
+ ui->send_message->setDisabled(true);
+ ui->chat_message->setDisabled(true);
+}
+
+void ChatRoom::Enable() {
+ ui->send_message->setEnabled(true);
+ ui->chat_message->setEnabled(true);
+}
+
+void ChatRoom::OnChatReceive(const Network::ChatEntry& chat) {
+ if (!ValidateMessage(chat.message)) {
+ return;
+ }
+ if (auto room = Network::GetRoomMember().lock()) {
+ // get the id of the player
+ auto members = room->GetMemberInformation();
+ auto it = std::find_if(members.begin(), members.end(),
+ [&chat](const Network::RoomMember::MemberInformation& member) {
+ return member.nickname == chat.nickname;
+ });
+ if (it == members.end()) {
+ NGLOG_INFO(Network, "Chat message received from unknown player. Ignoring it.");
+ return;
+ }
+ auto player = std::distance(members.begin(), it);
+ ChatMessage m(chat);
+ AppendChatMessage(m.GetPlayerChatMessage(player));
+ }
+}
+
+void ChatRoom::OnSendChat() {
+ if (auto room = Network::GetRoomMember().lock()) {
+ if (room->GetState() != Network::RoomMember::State::Joined) {
+ return;
+ }
+ auto message = ui->chat_message->text().toStdString();
+ if (!ValidateMessage(message)) {
+ return;
+ }
+ auto nick = room->GetNickname();
+ Network::ChatEntry chat{nick, message};
+
+ auto members = room->GetMemberInformation();
+ auto it = std::find_if(members.begin(), members.end(),
+ [&chat](const Network::RoomMember::MemberInformation& member) {
+ return member.nickname == chat.nickname;
+ });
+ if (it == members.end()) {
+ NGLOG_INFO(Network, "Cannot find self in the player list when sending a message.");
+ }
+ auto player = std::distance(members.begin(), it);
+ ChatMessage m(chat);
+ room->SendChatMessage(message);
+ AppendChatMessage(m.GetPlayerChatMessage(player));
+ ui->chat_message->clear();
+ }
+}
+
+void ChatRoom::SetPlayerList(const Network::RoomMember::MemberList& member_list) {
+ // TODO(B3N30): Remember which row is selected
+ player_list->removeRows(0, player_list->rowCount());
+ for (const auto& member : member_list) {
+ if (member.nickname.empty())
+ continue;
+ QList l;
+ std::vector elements = {member.nickname, member.game_info.name};
+ for (const auto& item : elements) {
+ QStandardItem* child = new QStandardItem(QString::fromStdString(item));
+ child->setEditable(false);
+ l.append(child);
+ }
+ player_list->invisibleRootItem()->appendRow(l);
+ }
+ // TODO(B3N30): Restore row selection
+}
+
+void ChatRoom::OnChatTextChanged() {
+ if (ui->chat_message->text().length() > Network::MaxMessageSize)
+ ui->chat_message->setText(ui->chat_message->text().left(Network::MaxMessageSize));
+}
diff --git a/src/citra_qt/multiplayer/chat_room.h b/src/citra_qt/multiplayer/chat_room.h
new file mode 100644
index 000000000..4604c3395
--- /dev/null
+++ b/src/citra_qt/multiplayer/chat_room.h
@@ -0,0 +1,58 @@
+// Copyright 2017 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+#include "network/network.h"
+
+namespace Ui {
+class ChatRoom;
+}
+
+namespace Core {
+class AnnounceMultiplayerSession;
+}
+
+class ConnectionError;
+class ComboBoxProxyModel;
+
+class ChatMessage;
+
+class ChatRoom : public QWidget {
+ Q_OBJECT
+
+public:
+ explicit ChatRoom(QWidget* parent);
+ void SetPlayerList(const Network::RoomMember::MemberList& member_list);
+ void Clear();
+ void AppendStatusMessage(const QString& msg);
+ ~ChatRoom();
+
+public slots:
+ void OnRoomUpdate(const Network::RoomInformation& info);
+ void OnChatReceive(const Network::ChatEntry&);
+ void OnSendChat();
+ void OnChatTextChanged();
+ void Disable();
+ void Enable();
+
+signals:
+ void ChatReceived(const Network::ChatEntry&);
+
+private:
+ static constexpr u32 max_chat_lines = 1000;
+ void AppendChatMessage(const QString&);
+ bool ValidateMessage(const std::string&);
+ QStandardItemModel* player_list;
+ std::unique_ptr ui;
+};
+
+Q_DECLARE_METATYPE(Network::ChatEntry);
+Q_DECLARE_METATYPE(Network::RoomInformation);
+Q_DECLARE_METATYPE(Network::RoomMember::State);
diff --git a/src/citra_qt/multiplayer/chat_room.ui b/src/citra_qt/multiplayer/chat_room.ui
new file mode 100644
index 000000000..8bb1899c0
--- /dev/null
+++ b/src/citra_qt/multiplayer/chat_room.ui
@@ -0,0 +1,59 @@
+
+
+ ChatRoom
+
+
+
+ 0
+ 0
+ 607
+ 432
+
+
+
+ Room Window
+
+
+ -
+
+
+ -
+
+
-
+
+
+ false
+
+
+ true
+
+
+ Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse
+
+
+
+ -
+
+
-
+
+
+ Send Chat Message
+
+
+
+ -
+
+
+ Send Message
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/citra_qt/multiplayer/client_room.cpp b/src/citra_qt/multiplayer/client_room.cpp
new file mode 100644
index 000000000..60f488587
--- /dev/null
+++ b/src/citra_qt/multiplayer/client_room.cpp
@@ -0,0 +1,87 @@
+// Copyright 2017 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include "citra_qt/game_list_p.h"
+#include "citra_qt/multiplayer/client_room.h"
+#include "citra_qt/multiplayer/message.h"
+#include "common/logging/log.h"
+#include "core/announce_multiplayer_session.h"
+#include "ui_client_room.h"
+
+ClientRoomWindow::ClientRoomWindow(QWidget* parent)
+ : QDialog(parent, Qt::WindowTitleHint | Qt::WindowCloseButtonHint | Qt::WindowSystemMenuHint),
+ ui(std::make_unique()) {
+ ui->setupUi(this);
+
+ // setup the callbacks for network updates
+ if (auto member = Network::GetRoomMember().lock()) {
+ member->BindOnRoomInformationChanged(
+ [this](const Network::RoomInformation& info) { emit RoomInformationChanged(info); });
+ member->BindOnStateChanged(
+ [this](const Network::RoomMember::State& state) { emit StateChanged(state); });
+
+ connect(this, &ClientRoomWindow::RoomInformationChanged, this,
+ &ClientRoomWindow::OnRoomUpdate);
+ connect(this, &ClientRoomWindow::StateChanged, this, &::ClientRoomWindow::OnStateChange);
+ } else {
+ // TODO (jroweboy) network was not initialized?
+ }
+
+ connect(ui->disconnect, &QPushButton::pressed, [this] { Disconnect(); });
+ ui->disconnect->setDefault(false);
+ ui->disconnect->setAutoDefault(false);
+ UpdateView();
+}
+
+ClientRoomWindow::~ClientRoomWindow() = default;
+
+void ClientRoomWindow::OnRoomUpdate(const Network::RoomInformation& info) {
+ UpdateView();
+}
+
+void ClientRoomWindow::OnStateChange(const Network::RoomMember::State& state) {
+ if (state == Network::RoomMember::State::Joined) {
+ ui->chat->Clear();
+ ui->chat->AppendStatusMessage(tr("Connected"));
+ }
+ UpdateView();
+}
+
+void ClientRoomWindow::Disconnect() {
+ if (!NetworkMessage::WarnDisconnect()) {
+ return;
+ }
+ if (auto member = Network::GetRoomMember().lock()) {
+ member->Leave();
+ ui->chat->AppendStatusMessage(tr("Disconnected"));
+ close();
+ }
+}
+
+void ClientRoomWindow::UpdateView() {
+ if (auto member = Network::GetRoomMember().lock()) {
+ if (member->IsConnected()) {
+ ui->chat->Enable();
+ ui->disconnect->setEnabled(true);
+ auto memberlist = member->GetMemberInformation();
+ ui->chat->SetPlayerList(memberlist);
+ const auto information = member->GetRoomInformation();
+ setWindowTitle(QString(tr("%1 (%2/%3 members) - connected"))
+ .arg(QString::fromStdString(information.name))
+ .arg(memberlist.size())
+ .arg(information.member_slots));
+ return;
+ }
+ }
+ // TODO(B3N30): can't get RoomMember*, show error and close window
+ close();
+}
diff --git a/src/citra_qt/multiplayer/client_room.h b/src/citra_qt/multiplayer/client_room.h
new file mode 100644
index 000000000..8e8ee24eb
--- /dev/null
+++ b/src/citra_qt/multiplayer/client_room.h
@@ -0,0 +1,34 @@
+// Copyright 2017 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+#pragma once
+
+#include "citra_qt/multiplayer/chat_room.h"
+
+namespace Ui {
+class ClientRoom;
+}
+
+class ClientRoomWindow : public QDialog {
+ Q_OBJECT
+
+public:
+ explicit ClientRoomWindow(QWidget* parent);
+ ~ClientRoomWindow();
+
+public slots:
+ void OnRoomUpdate(const Network::RoomInformation&);
+ void OnStateChange(const Network::RoomMember::State&);
+
+signals:
+ void RoomInformationChanged(const Network::RoomInformation&);
+ void StateChanged(const Network::RoomMember::State&);
+
+private:
+ void Disconnect();
+ void UpdateView();
+
+ QStandardItemModel* player_list;
+ std::unique_ptr ui;
+};
diff --git a/src/citra_qt/multiplayer/client_room.ui b/src/citra_qt/multiplayer/client_room.ui
new file mode 100644
index 000000000..d83c088c2
--- /dev/null
+++ b/src/citra_qt/multiplayer/client_room.ui
@@ -0,0 +1,63 @@
+
+
+ ClientRoom
+
+
+
+ 0
+ 0
+ 607
+ 432
+
+
+
+ Room Window
+
+
+ -
+
+
-
+
+
+ 0
+
+
-
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+ -
+
+
+ Leave Room
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+ ChatRoom
+ QWidget
+
+ 1
+
+
+
+
+
diff --git a/src/citra_qt/multiplayer/direct_connect.cpp b/src/citra_qt/multiplayer/direct_connect.cpp
new file mode 100644
index 000000000..a9b64c98c
--- /dev/null
+++ b/src/citra_qt/multiplayer/direct_connect.cpp
@@ -0,0 +1,113 @@
+// Copyright 2017 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include "citra_qt/main.h"
+#include "citra_qt/multiplayer/client_room.h"
+#include "citra_qt/multiplayer/direct_connect.h"
+#include "citra_qt/multiplayer/message.h"
+#include "citra_qt/multiplayer/state.h"
+#include "citra_qt/multiplayer/validation.h"
+#include "citra_qt/ui_settings.h"
+#include "core/settings.h"
+#include "network/network.h"
+#include "ui_direct_connect.h"
+
+enum class ConnectionType : u8 { TraversalServer, IP };
+
+DirectConnectWindow::DirectConnectWindow(QWidget* parent)
+ : QDialog(parent, Qt::WindowTitleHint | Qt::WindowCloseButtonHint | Qt::WindowSystemMenuHint),
+ ui(std::make_unique()) {
+
+ ui->setupUi(this);
+
+ // setup the watcher for background connections
+ watcher = new QFutureWatcher;
+ connect(watcher, &QFutureWatcher::finished, this, &DirectConnectWindow::OnConnection);
+
+ ui->nickname->setValidator(validation.GetNickname());
+ ui->nickname->setText(UISettings::values.nickname);
+ ui->ip->setValidator(validation.GetIP());
+ ui->ip->setText(UISettings::values.ip);
+ ui->port->setValidator(validation.GetPort());
+ ui->port->setText(UISettings::values.port);
+
+ // TODO(jroweboy): Show or hide the connection options based on the current value of the combo
+ // box. Add this back in when the traversal server support is added.
+ connect(ui->connect, &QPushButton::pressed, this, &DirectConnectWindow::Connect);
+}
+
+DirectConnectWindow::~DirectConnectWindow() = default;
+
+void DirectConnectWindow::Connect() {
+ if (!ui->nickname->hasAcceptableInput()) {
+ NetworkMessage::ShowError(NetworkMessage::USERNAME_NOT_VALID);
+ return;
+ }
+ if (const auto member = Network::GetRoomMember().lock()) {
+ if (member->IsConnected() && !NetworkMessage::WarnDisconnect()) {
+ return;
+ }
+ }
+ switch (static_cast(ui->connection_type->currentIndex())) {
+ case ConnectionType::TraversalServer:
+ break;
+ case ConnectionType::IP:
+ if (!ui->ip->hasAcceptableInput()) {
+ NetworkMessage::ShowError(NetworkMessage::IP_ADDRESS_NOT_VALID);
+ return;
+ }
+ if (!ui->port->hasAcceptableInput()) {
+ NetworkMessage::ShowError(NetworkMessage::PORT_NOT_VALID);
+ return;
+ }
+ break;
+ }
+
+ // Store settings
+ UISettings::values.nickname = ui->nickname->text();
+ UISettings::values.ip = ui->ip->text();
+ UISettings::values.port = (ui->port->isModified() && !ui->port->text().isEmpty())
+ ? ui->port->text()
+ : UISettings::values.port;
+ Settings::Apply();
+
+ // attempt to connect in a different thread
+ QFuture f = QtConcurrent::run([&] {
+ if (auto room_member = Network::GetRoomMember().lock()) {
+ auto port = UISettings::values.port.toUInt();
+ room_member->Join(ui->nickname->text().toStdString(),
+ ui->ip->text().toStdString().c_str(), port, 0,
+ Network::NoPreferredMac, ui->password->text().toStdString().c_str());
+ }
+ });
+ watcher->setFuture(f);
+ // and disable widgets and display a connecting while we wait
+ BeginConnecting();
+}
+
+void DirectConnectWindow::BeginConnecting() {
+ ui->connect->setEnabled(false);
+ ui->connect->setText(tr("Connecting"));
+}
+
+void DirectConnectWindow::EndConnecting() {
+ ui->connect->setEnabled(true);
+ ui->connect->setText(tr("Connect"));
+}
+
+void DirectConnectWindow::OnConnection() {
+ EndConnecting();
+
+ if (auto room_member = Network::GetRoomMember().lock()) {
+ if (room_member->GetState() == Network::RoomMember::State::Joined) {
+ close();
+ }
+ }
+}
diff --git a/src/citra_qt/multiplayer/direct_connect.h b/src/citra_qt/multiplayer/direct_connect.h
new file mode 100644
index 000000000..de167c1f9
--- /dev/null
+++ b/src/citra_qt/multiplayer/direct_connect.h
@@ -0,0 +1,41 @@
+// Copyright 2017 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+#pragma once
+
+#include
+#include
+#include
+#include "citra_qt/multiplayer/validation.h"
+
+namespace Ui {
+class DirectConnect;
+}
+
+class DirectConnectWindow : public QDialog {
+ Q_OBJECT
+
+public:
+ explicit DirectConnectWindow(QWidget* parent = nullptr);
+ ~DirectConnectWindow();
+
+signals:
+ /**
+ * Signalled by this widget when it is closing itself and destroying any state such as
+ * connections that it might have.
+ */
+ void Closed();
+
+private slots:
+ void OnConnection();
+
+private:
+ void Connect();
+ void BeginConnecting();
+ void EndConnecting();
+
+ QFutureWatcher* watcher;
+ std::unique_ptr ui;
+ Validation validation;
+};
diff --git a/src/citra_qt/multiplayer/direct_connect.ui b/src/citra_qt/multiplayer/direct_connect.ui
new file mode 100644
index 000000000..681b6bf69
--- /dev/null
+++ b/src/citra_qt/multiplayer/direct_connect.ui
@@ -0,0 +1,168 @@
+
+
+ DirectConnect
+
+
+
+ 0
+ 0
+ 455
+ 161
+
+
+
+ Direct Connect
+
+
+ -
+
+
-
+
+
-
+
+
+ 0
+
+
+ 0
+
+
-
+
+
-
+
+ IP Address
+
+
+
+
+ -
+
+
+
+ 5
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+
-
+
+
+ IP
+
+
+
+ -
+
+
+ <html><head/><body><p>IPv4 address of the host</p></body></html>
+
+
+ 16
+
+
+
+ -
+
+
+ Port
+
+
+
+ -
+
+
+ <html><head/><body><p>Port number the host is listening on</p></body></html>
+
+
+ 5
+
+
+ 24872
+
+
+
+
+
+
+
+
+ -
+
+
-
+
+
+ Nickname
+
+
+
+ -
+
+
+ 20
+
+
+
+ -
+
+
+ Password
+
+
+
+ -
+
+
+
+
+
+
+ -
+
+
+ Qt::Vertical
+
+
+
+ 20
+ 20
+
+
+
+
+ -
+
+
-
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+ -
+
+
+ Connect
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/citra_qt/multiplayer/host_room.cpp b/src/citra_qt/multiplayer/host_room.cpp
new file mode 100644
index 000000000..a57f31052
--- /dev/null
+++ b/src/citra_qt/multiplayer/host_room.cpp
@@ -0,0 +1,157 @@
+// Copyright 2017 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include "citra_qt/game_list_p.h"
+#include "citra_qt/main.h"
+#include "citra_qt/multiplayer/host_room.h"
+#include "citra_qt/multiplayer/message.h"
+#include "citra_qt/multiplayer/state.h"
+#include "citra_qt/multiplayer/validation.h"
+#include "citra_qt/ui_settings.h"
+#include "common/logging/log.h"
+#include "core/announce_multiplayer_session.h"
+#include "core/settings.h"
+#include "ui_host_room.h"
+
+HostRoomWindow::HostRoomWindow(QWidget* parent, QStandardItemModel* list,
+ std::shared_ptr session)
+ : QDialog(parent, Qt::WindowTitleHint | Qt::WindowCloseButtonHint | Qt::WindowSystemMenuHint),
+ ui(std::make_unique()), announce_multiplayer_session(session), game_list(list) {
+ ui->setupUi(this);
+
+ // set up validation for all of the fields
+ ui->room_name->setValidator(validation.GetRoomName());
+ ui->username->setValidator(validation.GetNickname());
+ ui->port->setValidator(validation.GetPort());
+ ui->port->setPlaceholderText(QString::number(Network::DefaultRoomPort));
+
+ // Create a proxy to the game list to display the list of preferred games
+ proxy = new ComboBoxProxyModel;
+ proxy->setSourceModel(game_list);
+ proxy->sort(0, Qt::AscendingOrder);
+ ui->game_list->setModel(proxy);
+
+ // Connect all the widgets to the appropriate events
+ connect(ui->host, &QPushButton::pressed, this, &HostRoomWindow::Host);
+
+ // Restore the settings:
+ ui->username->setText(UISettings::values.room_nickname);
+ ui->room_name->setText(UISettings::values.room_name);
+ ui->port->setText(UISettings::values.room_port);
+ ui->max_player->setValue(UISettings::values.max_player);
+ int index = UISettings::values.host_type;
+ if (index < ui->host_type->count()) {
+ ui->host_type->setCurrentIndex(index);
+ }
+ index = ui->game_list->findData(UISettings::values.game_id, GameListItemPath::ProgramIdRole);
+ if (index != -1) {
+ ui->game_list->setCurrentIndex(index);
+ }
+}
+
+HostRoomWindow::~HostRoomWindow() = default;
+
+void HostRoomWindow::Host() {
+ if (!ui->username->hasAcceptableInput()) {
+ NetworkMessage::ShowError(NetworkMessage::USERNAME_NOT_VALID);
+ return;
+ }
+ if (!ui->room_name->hasAcceptableInput()) {
+ NetworkMessage::ShowError(NetworkMessage::ROOMNAME_NOT_VALID);
+ return;
+ }
+ if (!ui->port->hasAcceptableInput()) {
+ NetworkMessage::ShowError(NetworkMessage::PORT_NOT_VALID);
+ return;
+ }
+ if (auto member = Network::GetRoomMember().lock()) {
+ if (member->IsConnected()) {
+ auto parent = static_cast(parentWidget());
+ if (!parent->OnCloseRoom()) {
+ close();
+ return;
+ }
+ }
+ ui->host->setDisabled(true);
+
+ auto game_name = ui->game_list->currentData(Qt::DisplayRole).toString();
+ auto game_id = ui->game_list->currentData(GameListItemPath::ProgramIdRole).toLongLong();
+ auto port = ui->port->isModified() ? ui->port->text().toInt() : Network::DefaultRoomPort;
+ auto password = ui->password->text().toStdString();
+ if (auto room = Network::GetRoom().lock()) {
+ bool created = room->Create(ui->room_name->text().toStdString(), "", port, password,
+ ui->max_player->value(), game_name.toStdString(), game_id);
+ if (!created) {
+ NetworkMessage::ShowError(NetworkMessage::COULD_NOT_CREATE_ROOM);
+ NGLOG_ERROR(Network, "Could not create room!");
+ ui->host->setEnabled(true);
+ return;
+ }
+ }
+ member->Join(ui->username->text().toStdString(), "127.0.0.1", port, 0,
+ Network::NoPreferredMac, password);
+
+ // Store settings
+ UISettings::values.room_nickname = ui->username->text();
+ UISettings::values.room_name = ui->room_name->text();
+ UISettings::values.game_id =
+ ui->game_list->currentData(GameListItemPath::ProgramIdRole).toLongLong();
+ UISettings::values.max_player = ui->max_player->value();
+
+ UISettings::values.host_type = ui->host_type->currentIndex();
+ UISettings::values.room_port = (ui->port->isModified() && !ui->port->text().isEmpty())
+ ? ui->port->text()
+ : QString::number(Network::DefaultRoomPort);
+ Settings::Apply();
+ OnConnection();
+ }
+}
+
+void HostRoomWindow::OnConnection() {
+ ui->host->setEnabled(true);
+ if (auto room_member = Network::GetRoomMember().lock()) {
+ if (room_member->GetState() == Network::RoomMember::State::Joining) {
+ // Start the announce session if they chose Public
+ if (ui->host_type->currentIndex() == 0) {
+ if (auto session = announce_multiplayer_session.lock()) {
+ session->Start();
+ } else {
+ NGLOG_ERROR(Network, "Starting announce session failed");
+ }
+ }
+ close();
+ }
+ }
+}
+
+QVariant ComboBoxProxyModel::data(const QModelIndex& idx, int role) const {
+ if (role != Qt::DisplayRole) {
+ auto val = QSortFilterProxyModel::data(idx, role);
+ // If its the icon, shrink it to 16x16
+ if (role == Qt::DecorationRole)
+ val = val.value().scaled(16, 16, Qt::KeepAspectRatio);
+ return val;
+ }
+ std::string filename;
+ Common::SplitPath(
+ QSortFilterProxyModel::data(idx, GameListItemPath::FullPathRole).toString().toStdString(),
+ nullptr, &filename, nullptr);
+ QString title = QSortFilterProxyModel::data(idx, GameListItemPath::TitleRole).toString();
+ return title.isEmpty() ? QString::fromStdString(filename) : title;
+}
+
+bool ComboBoxProxyModel::lessThan(const QModelIndex& left, const QModelIndex& right) const {
+ // TODO(jroweboy): Sort by game title not filename
+ auto leftData = left.data(Qt::DisplayRole).toString();
+ auto rightData = right.data(Qt::DisplayRole).toString();
+ return leftData.compare(rightData) < 0;
+}
diff --git a/src/citra_qt/multiplayer/host_room.h b/src/citra_qt/multiplayer/host_room.h
new file mode 100644
index 000000000..574dc2824
--- /dev/null
+++ b/src/citra_qt/multiplayer/host_room.h
@@ -0,0 +1,69 @@
+// Copyright 2017 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+#include "citra_qt/multiplayer/chat_room.h"
+#include "citra_qt/multiplayer/validation.h"
+#include "network/network.h"
+
+namespace Ui {
+class HostRoom;
+}
+
+namespace Core {
+class AnnounceMultiplayerSession;
+}
+
+class ConnectionError;
+class ComboBoxProxyModel;
+
+class ChatMessage;
+
+class HostRoomWindow : public QDialog {
+ Q_OBJECT
+
+public:
+ explicit HostRoomWindow(QWidget* parent, QStandardItemModel* list,
+ std::shared_ptr session);
+ ~HostRoomWindow();
+
+private slots:
+ /**
+ * Handler for connection status changes. Launches the chat window if successful or
+ * displays an error
+ */
+ void OnConnection();
+
+private:
+ void Host();
+
+ std::weak_ptr announce_multiplayer_session;
+ QStandardItemModel* game_list;
+ ComboBoxProxyModel* proxy;
+ std::unique_ptr ui;
+ Validation validation;
+};
+
+/**
+ * Proxy Model for the game list combo box so we can reuse the game list model while still
+ * displaying the fields slightly differently
+ */
+class ComboBoxProxyModel : public QSortFilterProxyModel {
+ Q_OBJECT
+
+public:
+ int columnCount(const QModelIndex& idx) const override {
+ return 1;
+ }
+
+ QVariant data(const QModelIndex& idx, int role) const override;
+
+ bool lessThan(const QModelIndex& left, const QModelIndex& right) const override;
+};
diff --git a/src/citra_qt/multiplayer/host_room.ui b/src/citra_qt/multiplayer/host_room.ui
new file mode 100644
index 000000000..7edf90628
--- /dev/null
+++ b/src/citra_qt/multiplayer/host_room.ui
@@ -0,0 +1,179 @@
+
+
+ HostRoom
+
+
+
+ 0
+ 0
+ 607
+ 165
+
+
+
+ Create Room
+
+
+ -
+
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+
-
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
-
+
+
+ Room Name
+
+
+
+ -
+
+
+ 50
+
+
+
+ -
+
+
+ Preferred Game
+
+
+
+ -
+
+
+ -
+
+
+ Max Players
+
+
+
+ -
+
+
+ 1
+
+
+ 16
+
+
+ 8
+
+
+
+
+
+ -
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
-
+
+
+ -
+
+
+ Username
+
+
+
+ -
+
+
+ QLineEdit::PasswordEchoOnEdit
+
+
+ (Leave blank for open game)
+
+
+
+ -
+
+
+ Qt::ImhDigitsOnly
+
+
+ 5
+
+
+
+ -
+
+
+ Password
+
+
+
+ -
+
+
+ Port
+
+
+
+
+
+
+
+
+ -
+
+
+ 0
+
+
-
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+ -
+
+
-
+
+ Public
+
+
+ -
+
+ Unlisted
+
+
+
+
+ -
+
+
+ Host Room
+
+
+
+
+
+
+
+
+
+
diff --git a/src/citra_qt/multiplayer/lobby.cpp b/src/citra_qt/multiplayer/lobby.cpp
new file mode 100644
index 000000000..0b58091cb
--- /dev/null
+++ b/src/citra_qt/multiplayer/lobby.cpp
@@ -0,0 +1,305 @@
+// Copyright 2017 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+#include
+#include
+#include
+#include "citra_qt/game_list_p.h"
+#include "citra_qt/main.h"
+#include "citra_qt/multiplayer/client_room.h"
+#include "citra_qt/multiplayer/lobby.h"
+#include "citra_qt/multiplayer/lobby_p.h"
+#include "citra_qt/multiplayer/message.h"
+#include "citra_qt/multiplayer/state.h"
+#include "citra_qt/multiplayer/validation.h"
+#include "citra_qt/ui_settings.h"
+#include "common/logging/log.h"
+#include "core/settings.h"
+#include "network/network.h"
+
+Lobby::Lobby(QWidget* parent, QStandardItemModel* list,
+ std::shared_ptr session)
+ : QDialog(parent, Qt::WindowTitleHint | Qt::WindowCloseButtonHint | Qt::WindowSystemMenuHint),
+ ui(std::make_unique()), announce_multiplayer_session(session), game_list(list) {
+ ui->setupUi(this);
+
+ // setup the watcher for background connections
+ watcher = new QFutureWatcher;
+ connect(watcher, &QFutureWatcher::finished, [&] { joining = false; });
+
+ model = new QStandardItemModel(ui->room_list);
+ proxy = new LobbyFilterProxyModel(this, game_list);
+ proxy->setSourceModel(model);
+ proxy->setDynamicSortFilter(true);
+ proxy->setFilterCaseSensitivity(Qt::CaseInsensitive);
+ proxy->setSortLocaleAware(true);
+ ui->room_list->setModel(proxy);
+ ui->room_list->header()->setSectionResizeMode(QHeaderView::Interactive);
+ ui->room_list->header()->stretchLastSection();
+ ui->room_list->setAlternatingRowColors(true);
+ ui->room_list->setSelectionMode(QHeaderView::SingleSelection);
+ ui->room_list->setSelectionBehavior(QHeaderView::SelectRows);
+ ui->room_list->setVerticalScrollMode(QHeaderView::ScrollPerPixel);
+ ui->room_list->setHorizontalScrollMode(QHeaderView::ScrollPerPixel);
+ ui->room_list->setSortingEnabled(true);
+ ui->room_list->setEditTriggers(QHeaderView::NoEditTriggers);
+ ui->room_list->setExpandsOnDoubleClick(false);
+ ui->room_list->setContextMenuPolicy(Qt::CustomContextMenu);
+
+ ui->nickname->setValidator(validation.GetNickname());
+ ui->nickname->setText(UISettings::values.nickname);
+
+ // UI Buttons
+ MultiplayerState* p = reinterpret_cast(parent);
+ connect(ui->refresh_list, &QPushButton::pressed, this, &Lobby::RefreshLobby);
+ connect(ui->games_owned, &QCheckBox::stateChanged, proxy,
+ &LobbyFilterProxyModel::SetFilterOwned);
+ connect(ui->hide_full, &QCheckBox::stateChanged, proxy, &LobbyFilterProxyModel::SetFilterFull);
+ connect(ui->search, &QLineEdit::textChanged, proxy, &LobbyFilterProxyModel::SetFilterSearch);
+ connect(ui->room_list, &QTreeView::doubleClicked, this, &Lobby::OnJoinRoom);
+ connect(ui->room_list, &QTreeView::clicked, this, &Lobby::OnExpandRoom);
+
+ // Actions
+ connect(this, &Lobby::LobbyRefreshed, this, &Lobby::OnRefreshLobby);
+
+ // manually start a refresh when the window is opening
+ // TODO(jroweboy): if this refresh is slow for people with bad internet, then don't do it as
+ // part of the constructor, but offload the refresh until after the window shown. perhaps emit a
+ // refreshroomlist signal from places that open the lobby
+ RefreshLobby();
+}
+
+QString Lobby::PasswordPrompt() {
+ bool ok;
+ const QString text =
+ QInputDialog::getText(this, tr("Password Required to Join"), tr("Password:"),
+ QLineEdit::Normal, tr("Password"), &ok);
+ return ok ? text : QString();
+}
+
+void Lobby::OnExpandRoom(const QModelIndex& index) {
+ QModelIndex member_index = proxy->index(index.row(), Column::MEMBER);
+ auto member_list = proxy->data(member_index, LobbyItemMemberList::MemberListRole).toList();
+}
+
+void Lobby::OnJoinRoom(const QModelIndex& source) {
+ if (joining) {
+ return;
+ }
+ joining = true;
+ QModelIndex index = source;
+ // If the user double clicks on a child row (aka the player list) then use the parent instead
+ if (source.parent() != QModelIndex()) {
+ index = source.parent();
+ }
+ if (!ui->nickname->hasAcceptableInput()) {
+ NetworkMessage::ShowError(NetworkMessage::USERNAME_NOT_VALID);
+ return;
+ }
+ if (const auto member = Network::GetRoomMember().lock()) {
+ if (member->IsConnected()) {
+ if (!NetworkMessage::WarnDisconnect()) {
+ return;
+ }
+ }
+ }
+
+ // Get a password to pass if the room is password protected
+ QModelIndex password_index = proxy->index(index.row(), Column::ROOM_NAME);
+ bool has_password = proxy->data(password_index, LobbyItemName::PasswordRole).toBool();
+ const std::string password = has_password ? PasswordPrompt().toStdString() : "";
+ if (has_password && password.empty()) {
+ return;
+ }
+
+ // attempt to connect in a different thread
+ QFuture f = QtConcurrent::run([&, password] {
+ if (auto room_member = Network::GetRoomMember().lock()) {
+ QModelIndex connection_index = proxy->index(index.row(), Column::HOST);
+ const std::string nickname = ui->nickname->text().toStdString();
+ const std::string ip =
+ proxy->data(connection_index, LobbyItemHost::HostIPRole).toString().toStdString();
+ int port = proxy->data(connection_index, LobbyItemHost::HostPortRole).toInt();
+ room_member->Join(nickname, ip.c_str(), port, 0, Network::NoPreferredMac, password);
+ }
+ });
+ watcher->setFuture(f);
+ // and disable widgets and display a connecting while we wait
+ QModelIndex connection_index = proxy->index(index.row(), Column::HOST);
+
+ // Save settings
+ UISettings::values.nickname = ui->nickname->text();
+ UISettings::values.ip = proxy->data(connection_index, LobbyItemHost::HostIPRole).toString();
+ UISettings::values.port = proxy->data(connection_index, LobbyItemHost::HostPortRole).toString();
+ Settings::Apply();
+}
+
+void Lobby::ResetModel() {
+ model->clear();
+ model->insertColumns(0, Column::TOTAL);
+ model->setHeaderData(Column::EXPAND, Qt::Horizontal, "", Qt::DisplayRole);
+ model->setHeaderData(Column::ROOM_NAME, Qt::Horizontal, tr("Room Name"), Qt::DisplayRole);
+ model->setHeaderData(Column::GAME_NAME, Qt::Horizontal, tr("Preferred Game"), Qt::DisplayRole);
+ model->setHeaderData(Column::HOST, Qt::Horizontal, tr("Host"), Qt::DisplayRole);
+ model->setHeaderData(Column::MEMBER, Qt::Horizontal, tr("Players"), Qt::DisplayRole);
+}
+
+void Lobby::RefreshLobby() {
+ if (auto session = announce_multiplayer_session.lock()) {
+ ResetModel();
+ room_list_future = session->GetRoomList([&]() { emit LobbyRefreshed(); });
+ ui->refresh_list->setEnabled(false);
+ ui->refresh_list->setText(tr("Refreshing"));
+ } else {
+ // TODO(jroweboy): Display an error box about announce couldn't be started
+ }
+}
+
+void Lobby::OnRefreshLobby() {
+ AnnounceMultiplayerRoom::RoomList new_room_list = room_list_future.get();
+ for (auto room : new_room_list) {
+ // find the icon for the game if this person owns that game.
+ QPixmap smdh_icon;
+ for (int r = 0; r < game_list->rowCount(); ++r) {
+ auto index = game_list->index(r, 0);
+ auto game_id = game_list->data(index, GameListItemPath::ProgramIdRole).toULongLong();
+ if (game_id != 0 && room.preferred_game_id == game_id) {
+ smdh_icon = game_list->data(index, Qt::DecorationRole).value();
+ }
+ }
+
+ QList members;
+ for (auto member : room.members) {
+ QVariant var;
+ var.setValue(LobbyMember{QString::fromStdString(member.name), member.game_id,
+ QString::fromStdString(member.game_name)});
+ members.append(var);
+ }
+
+ auto first_item = new LobbyItem();
+ auto row = QList({
+ first_item,
+ new LobbyItemName(room.has_password, QString::fromStdString(room.name)),
+ new LobbyItemGame(room.preferred_game_id, QString::fromStdString(room.preferred_game),
+ smdh_icon),
+ new LobbyItemHost(QString::fromStdString(room.owner), QString::fromStdString(room.ip),
+ room.port),
+ new LobbyItemMemberList(members, room.max_player),
+ });
+ model->appendRow(row);
+ // To make the rows expandable, add the member data as a child of the first column of the
+ // rows with people in them and have qt set them to colspan after the model is finished
+ // resetting
+ if (room.members.size() > 0) {
+ first_item->appendRow(new LobbyItemExpandedMemberList(members));
+ }
+ }
+
+ // Reenable the refresh button and resize the columns
+ ui->refresh_list->setEnabled(true);
+ ui->refresh_list->setText(tr("Refresh List"));
+ ui->room_list->header()->stretchLastSection();
+ for (int i = 0; i < Column::TOTAL - 1; ++i) {
+ ui->room_list->resizeColumnToContents(i);
+ }
+
+ // Set the member list child items to span all columns
+ for (int i = 0; i < proxy->rowCount(); i++) {
+ auto parent = model->item(i, 0);
+ if (parent->hasChildren()) {
+ ui->room_list->setFirstColumnSpanned(0, proxy->index(i, 0), true);
+ }
+ }
+}
+
+LobbyFilterProxyModel::LobbyFilterProxyModel(QWidget* parent, QStandardItemModel* list)
+ : QSortFilterProxyModel(parent), game_list(list) {}
+
+bool LobbyFilterProxyModel::filterAcceptsRow(int sourceRow, const QModelIndex& sourceParent) const {
+ // Prioritize filters by fastest to compute
+
+ // pass over any child rows (aka row that shows the players in the room)
+ if (sourceParent != QModelIndex()) {
+ return true;
+ }
+
+ // filter by filled rooms
+ if (filter_full) {
+ QModelIndex member_list = sourceModel()->index(sourceRow, Column::MEMBER, sourceParent);
+ int player_count =
+ sourceModel()->data(member_list, LobbyItemMemberList::MemberListRole).toList().size();
+ int max_players =
+ sourceModel()->data(member_list, LobbyItemMemberList::MaxPlayerRole).toInt();
+ if (player_count >= max_players) {
+ return false;
+ }
+ }
+
+ // filter by search parameters
+ if (!filter_search.isEmpty()) {
+ QModelIndex game_name = sourceModel()->index(sourceRow, Column::GAME_NAME, sourceParent);
+ QModelIndex room_name = sourceModel()->index(sourceRow, Column::ROOM_NAME, sourceParent);
+ QModelIndex host_name = sourceModel()->index(sourceRow, Column::HOST, sourceParent);
+ bool preferred_game_match = sourceModel()
+ ->data(game_name, LobbyItemGame::GameNameRole)
+ .toString()
+ .contains(filter_search, filterCaseSensitivity());
+ bool room_name_match = sourceModel()
+ ->data(room_name, LobbyItemName::NameRole)
+ .toString()
+ .contains(filter_search, filterCaseSensitivity());
+ bool username_match = sourceModel()
+ ->data(host_name, LobbyItemHost::HostUsernameRole)
+ .toString()
+ .contains(filter_search, filterCaseSensitivity());
+ if (!preferred_game_match && !room_name_match && !username_match) {
+ return false;
+ }
+ }
+
+ // filter by game owned
+ if (filter_owned) {
+ QModelIndex game_name = sourceModel()->index(sourceRow, Column::GAME_NAME, sourceParent);
+ QList owned_games;
+ for (int r = 0; r < game_list->rowCount(); ++r) {
+ owned_games.append(QModelIndex(game_list->index(r, 0)));
+ }
+ auto current_id = sourceModel()->data(game_name, LobbyItemGame::TitleIDRole).toLongLong();
+ if (current_id == 0) {
+ // TODO(jroweboy): homebrew often doesn't have a game id and this hides them
+ return false;
+ }
+ bool owned = false;
+ for (const auto& game : owned_games) {
+ auto game_id = game_list->data(game, GameListItemPath::ProgramIdRole).toLongLong();
+ if (current_id == game_id) {
+ owned = true;
+ }
+ }
+ if (!owned) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+void LobbyFilterProxyModel::sort(int column, Qt::SortOrder order) {
+ sourceModel()->sort(column, order);
+}
+
+void LobbyFilterProxyModel::SetFilterOwned(bool filter) {
+ filter_owned = filter;
+ invalidate();
+}
+
+void LobbyFilterProxyModel::SetFilterFull(bool filter) {
+ filter_full = filter;
+ invalidate();
+}
+
+void LobbyFilterProxyModel::SetFilterSearch(const QString& filter) {
+ filter_search = filter;
+ invalidate();
+}
diff --git a/src/citra_qt/multiplayer/lobby.h b/src/citra_qt/multiplayer/lobby.h
new file mode 100644
index 000000000..783225737
--- /dev/null
+++ b/src/citra_qt/multiplayer/lobby.h
@@ -0,0 +1,116 @@
+// Copyright 2017 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include "citra_qt/multiplayer/validation.h"
+#include "common/announce_multiplayer_room.h"
+#include "core/announce_multiplayer_session.h"
+#include "network/network.h"
+#include "ui_lobby.h"
+
+class LobbyModel;
+class LobbyFilterProxyModel;
+
+/**
+ * Listing of all public games pulled from services. The lobby should be simple enough for users to
+ * find the game they want to play, and join it.
+ */
+class Lobby : public QDialog {
+ Q_OBJECT
+
+public:
+ explicit Lobby(QWidget* parent, QStandardItemModel* list,
+ std::shared_ptr session);
+ ~Lobby() = default;
+
+public slots:
+ /**
+ * Begin the process to pull the latest room list from web services. After the listing is
+ * returned from web services, `LobbyRefreshed` will be signalled
+ */
+ void RefreshLobby();
+
+private slots:
+ /**
+ * Pulls the list of rooms from network and fills out the lobby model with the results
+ */
+ void OnRefreshLobby();
+
+ /**
+ * Handler for single clicking on a room in the list. Expands the treeitem to show player
+ * information for the people in the room
+ *
+ * index - The row of the proxy model that the user wants to join.
+ */
+ void OnExpandRoom(const QModelIndex&);
+
+ /**
+ * Handler for double clicking on a room in the list. Gathers the host ip and port and attempts
+ * to connect. Will also prompt for a password in case one is required.
+ *
+ * index - The row of the proxy model that the user wants to join.
+ */
+ void OnJoinRoom(const QModelIndex&);
+
+signals:
+ /**
+ * Signalled when the latest lobby data is retrieved.
+ */
+ void LobbyRefreshed();
+
+ void StateChanged(const Network::RoomMember::State&);
+
+private:
+ /**
+ * Removes all entries in the Lobby before refreshing.
+ */
+ void ResetModel();
+
+ /**
+ * Prompts for a password. Returns an empty QString if the user either did not provide a
+ * password or if the user closed the window.
+ */
+ QString PasswordPrompt();
+
+ QStandardItemModel* model;
+ QStandardItemModel* game_list;
+ LobbyFilterProxyModel* proxy;
+
+ std::future room_list_future;
+ std::weak_ptr announce_multiplayer_session;
+ std::unique_ptr ui;
+ QFutureWatcher* watcher;
+ Validation validation;
+ bool joining = false;
+};
+
+/**
+ * Proxy Model for filtering the lobby
+ */
+class LobbyFilterProxyModel : public QSortFilterProxyModel {
+ Q_OBJECT;
+
+public:
+ explicit LobbyFilterProxyModel(QWidget* parent, QStandardItemModel* list);
+ bool filterAcceptsRow(int sourceRow, const QModelIndex& sourceParent) const override;
+ void sort(int column, Qt::SortOrder order) override;
+
+public slots:
+ void SetFilterOwned(bool);
+ void SetFilterFull(bool);
+ void SetFilterSearch(const QString&);
+
+private:
+ QStandardItemModel* game_list;
+ bool filter_owned = false;
+ bool filter_full = false;
+ QString filter_search;
+};
diff --git a/src/citra_qt/multiplayer/lobby.ui b/src/citra_qt/multiplayer/lobby.ui
new file mode 100644
index 000000000..835d03238
--- /dev/null
+++ b/src/citra_qt/multiplayer/lobby.ui
@@ -0,0 +1,123 @@
+
+
+ Lobby
+
+
+
+ 0
+ 0
+ 903
+ 487
+
+
+
+ Public Room Browser
+
+
+ -
+
+
+ 3
+
+
-
+
+
+ 6
+
+
-
+
+
-
+
+
+ Nickname
+
+
+
+ -
+
+
+ Nickname
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+ -
+
+
+ Filters
+
+
+
+ -
+
+
+ Search
+
+
+ true
+
+
+
+ -
+
+
+ Games I Own
+
+
+
+ -
+
+
+ Hide Full Games
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+ -
+
+
+ Refresh Lobby
+
+
+
+
+
+
+
+ -
+
+
+ -
+
+
+
+
+
+
+
+
+
diff --git a/src/citra_qt/multiplayer/lobby_p.h b/src/citra_qt/multiplayer/lobby_p.h
new file mode 100644
index 000000000..3773f99de
--- /dev/null
+++ b/src/citra_qt/multiplayer/lobby_p.h
@@ -0,0 +1,207 @@
+// Copyright 2017 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+#pragma once
+
+#include
+#include
+#include
+#include
+#include "common/common_types.h"
+
+namespace Column {
+enum List {
+ EXPAND,
+ ROOM_NAME,
+ GAME_NAME,
+ HOST,
+ MEMBER,
+ TOTAL,
+};
+}
+
+class LobbyItem : public QStandardItem {
+public:
+ LobbyItem() = default;
+ explicit LobbyItem(const QString& string) : QStandardItem(string) {}
+ virtual ~LobbyItem() override = default;
+};
+
+class LobbyItemName : public LobbyItem {
+public:
+ static const int NameRole = Qt::UserRole + 1;
+ static const int PasswordRole = Qt::UserRole + 2;
+
+ LobbyItemName() = default;
+ explicit LobbyItemName(bool has_password, QString name) : LobbyItem() {
+ setData(name, NameRole);
+ setData(has_password, PasswordRole);
+ }
+
+ QVariant data(int role) const override {
+ if (role == Qt::DecorationRole) {
+ bool has_password = data(PasswordRole).toBool();
+ return has_password ? QIcon::fromTheme("lock").pixmap(16) : QIcon();
+ }
+ if (role != Qt::DisplayRole) {
+ return LobbyItem::data(role);
+ }
+ return data(NameRole).toString();
+ }
+
+ bool operator<(const QStandardItem& other) const override {
+ return data(NameRole).toString().localeAwareCompare(other.data(NameRole).toString()) < 0;
+ }
+};
+
+class LobbyItemGame : public LobbyItem {
+public:
+ static const int TitleIDRole = Qt::UserRole + 1;
+ static const int GameNameRole = Qt::UserRole + 2;
+ static const int GameIconRole = Qt::UserRole + 3;
+
+ LobbyItemGame() = default;
+ explicit LobbyItemGame(u64 title_id, QString game_name, QPixmap smdh_icon) {
+ setData(static_cast(title_id), TitleIDRole);
+ setData(game_name, GameNameRole);
+ if (!smdh_icon.isNull()) {
+ setData(smdh_icon, GameIconRole);
+ }
+ }
+
+ QVariant data(int role) const override {
+ if (role == Qt::DecorationRole) {
+ auto val = data(GameIconRole);
+ if (val.isValid()) {
+ val = val.value().scaled(16, 16, Qt::KeepAspectRatio);
+ }
+ return val;
+ } else if (role != Qt::DisplayRole) {
+ return LobbyItem::data(role);
+ }
+ return data(GameNameRole).toString();
+ }
+
+ bool operator<(const QStandardItem& other) const override {
+ return data(GameNameRole)
+ .toString()
+ .localeAwareCompare(other.data(GameNameRole).toString()) < 0;
+ }
+};
+
+class LobbyItemHost : public LobbyItem {
+public:
+ static const int HostUsernameRole = Qt::UserRole + 1;
+ static const int HostIPRole = Qt::UserRole + 2;
+ static const int HostPortRole = Qt::UserRole + 3;
+
+ LobbyItemHost() = default;
+ explicit LobbyItemHost(QString username, QString ip, u16 port) {
+ setData(username, HostUsernameRole);
+ setData(ip, HostIPRole);
+ setData(port, HostPortRole);
+ }
+
+ QVariant data(int role) const override {
+ if (role != Qt::DisplayRole) {
+ return LobbyItem::data(role);
+ }
+ return data(HostUsernameRole).toString();
+ }
+
+ bool operator<(const QStandardItem& other) const override {
+ return data(HostUsernameRole)
+ .toString()
+ .localeAwareCompare(other.data(HostUsernameRole).toString()) < 0;
+ }
+};
+
+class LobbyMember {
+public:
+ LobbyMember() = default;
+ LobbyMember(const LobbyMember& other) = default;
+ explicit LobbyMember(QString username, u64 title_id, QString game_name)
+ : username(std::move(username)), title_id(title_id), game_name(std::move(game_name)) {}
+ ~LobbyMember() = default;
+
+ QString GetUsername() const {
+ return username;
+ }
+ u64 GetTitleId() const {
+ return title_id;
+ }
+ QString GetGameName() const {
+ return game_name;
+ }
+
+private:
+ QString username;
+ u64 title_id;
+ QString game_name;
+};
+
+Q_DECLARE_METATYPE(LobbyMember);
+
+class LobbyItemMemberList : public LobbyItem {
+public:
+ static const int MemberListRole = Qt::UserRole + 1;
+ static const int MaxPlayerRole = Qt::UserRole + 2;
+
+ LobbyItemMemberList() = default;
+ explicit LobbyItemMemberList(QList members, u32 max_players) {
+ setData(members, MemberListRole);
+ setData(max_players, MaxPlayerRole);
+ }
+
+ QVariant data(int role) const override {
+ if (role != Qt::DisplayRole) {
+ return LobbyItem::data(role);
+ }
+ auto members = data(MemberListRole).toList();
+ return QString("%1 / %2").arg(QString::number(members.size()),
+ data(MaxPlayerRole).toString());
+ }
+
+ bool operator<(const QStandardItem& other) const override {
+ // sort by rooms that have the most players
+ int left_members = data(MemberListRole).toList().size();
+ int right_members = other.data(MemberListRole).toList().size();
+ return left_members < right_members;
+ }
+};
+
+/**
+ * Member information for when a lobby is expanded in the UI
+ */
+class LobbyItemExpandedMemberList : public LobbyItem {
+public:
+ static const int MemberListRole = Qt::UserRole + 1;
+
+ LobbyItemExpandedMemberList() = default;
+ explicit LobbyItemExpandedMemberList(QList members) {
+ setData(members, MemberListRole);
+ }
+
+ QVariant data(int role) const override {
+ if (role != Qt::DisplayRole) {
+ return LobbyItem::data(role);
+ }
+ auto members = data(MemberListRole).toList();
+ QString out;
+ bool first = true;
+ for (const auto& member : members) {
+ if (!first)
+ out += '\n';
+ const auto& m = member.value();
+ if (m.GetGameName().isEmpty()) {
+ out += QString(QObject::tr("%1 is not playing a game")).arg(m.GetUsername());
+ } else {
+ out +=
+ QString(QObject::tr("%1 is playing %2")).arg(m.GetUsername(), m.GetGameName());
+ }
+ first = false;
+ }
+ return out;
+ }
+};
diff --git a/src/citra_qt/multiplayer/message.cpp b/src/citra_qt/multiplayer/message.cpp
new file mode 100644
index 000000000..57cd7671c
--- /dev/null
+++ b/src/citra_qt/multiplayer/message.cpp
@@ -0,0 +1,62 @@
+// Copyright 2017 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+#include
+#include
+
+#include "citra_qt/multiplayer/message.h"
+
+namespace NetworkMessage {
+const ConnectionError USERNAME_NOT_VALID(
+ QT_TR_NOOP("Username is not valid. Must be 4 to 20 alphanumeric characters."));
+const ConnectionError ROOMNAME_NOT_VALID(
+ QT_TR_NOOP("Room name is not valid. Must be 4 to 20 alphanumeric characters."));
+const ConnectionError USERNAME_IN_USE(
+ QT_TR_NOOP("Username is already in use. Please choose another."));
+const ConnectionError IP_ADDRESS_NOT_VALID(QT_TR_NOOP("IP is not a valid IPv4 address."));
+const ConnectionError PORT_NOT_VALID(QT_TR_NOOP("Port must be a number between 0 to 65535."));
+const ConnectionError NO_INTERNET(
+ QT_TR_NOOP("Unable to find an internet connection. Check your internet settings."));
+const ConnectionError UNABLE_TO_CONNECT(
+ QT_TR_NOOP("Unable to connect to the host. Verify that the connection settings are correct. If "
+ "you still cannot connect, contact the room host and verify that the host is "
+ "properly configured with the external port forwarded."));
+const ConnectionError COULD_NOT_CREATE_ROOM(
+ QT_TR_NOOP("Creating a room failed. Please retry. Restarting Citra might be necessary."));
+const ConnectionError HOST_BANNED(
+ QT_TR_NOOP("The host of the room has banned you. Speak with the host to unban you "
+ "or try a different room."));
+const ConnectionError WRONG_VERSION(
+ QT_TR_NOOP("Version mismatch! Please update to the latest version of Citra. If the problem "
+ "persists, contact the room host and ask them to update the server."));
+const ConnectionError WRONG_PASSWORD(QT_TR_NOOP("Incorrect password."));
+const ConnectionError GENERIC_ERROR(
+ QT_TR_NOOP("An unknown error occured. If this error continues to occur, please open an issue"));
+const ConnectionError LOST_CONNECTION(QT_TR_NOOP("Connection to room lost. Try to reconnect."));
+const ConnectionError MAC_COLLISION(
+ QT_TR_NOOP("MAC address is already in use. Please choose another."));
+
+static bool WarnMessage(const std::string& title, const std::string& text) {
+ return QMessageBox::Ok == QMessageBox::warning(nullptr, QObject::tr(title.c_str()),
+ QObject::tr(text.c_str()),
+ QMessageBox::Ok | QMessageBox::Cancel);
+}
+
+void ShowError(const ConnectionError& e) {
+ QMessageBox::critical(nullptr, QObject::tr("Error"), QString::fromStdString(e.GetString()));
+}
+
+bool WarnCloseRoom() {
+ return WarnMessage(
+ QT_TR_NOOP("Leave Room"),
+ QT_TR_NOOP("You are about to close the room. Any network connections will be closed."));
+}
+
+bool WarnDisconnect() {
+ return WarnMessage(
+ QT_TR_NOOP("Disconnect"),
+ QT_TR_NOOP("You are about to leave the room. Any network connections will be closed."));
+}
+
+} // namespace NetworkMessage
diff --git a/src/citra_qt/multiplayer/message.h b/src/citra_qt/multiplayer/message.h
new file mode 100644
index 000000000..3b8613199
--- /dev/null
+++ b/src/citra_qt/multiplayer/message.h
@@ -0,0 +1,55 @@
+// Copyright 2017 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+#pragma once
+
+#include
+
+namespace NetworkMessage {
+
+class ConnectionError {
+
+public:
+ explicit ConnectionError(std::string str) : err(std::move(str)) {}
+ const std::string& GetString() const {
+ return err;
+ }
+
+private:
+ std::string err;
+};
+
+extern const ConnectionError USERNAME_NOT_VALID;
+extern const ConnectionError ROOMNAME_NOT_VALID;
+extern const ConnectionError USERNAME_IN_USE;
+extern const ConnectionError IP_ADDRESS_NOT_VALID;
+extern const ConnectionError PORT_NOT_VALID;
+extern const ConnectionError NO_INTERNET;
+extern const ConnectionError UNABLE_TO_CONNECT;
+extern const ConnectionError COULD_NOT_CREATE_ROOM;
+extern const ConnectionError HOST_BANNED;
+extern const ConnectionError WRONG_VERSION;
+extern const ConnectionError WRONG_PASSWORD;
+extern const ConnectionError GENERIC_ERROR;
+extern const ConnectionError LOST_CONNECTION;
+extern const ConnectionError MAC_COLLISION;
+
+/**
+ * Shows a standard QMessageBox with a error message
+ */
+void ShowError(const ConnectionError& e);
+
+/**
+ * Show a standard QMessageBox with a warning message about leaving the room
+ * return true if the user wants to close the network connection
+ */
+bool WarnCloseRoom();
+
+/**
+ * Show a standard QMessageBox with a warning message about disconnecting from the room
+ * return true if the user wants to disconnect
+ */
+bool WarnDisconnect();
+
+} // namespace NetworkMessage
diff --git a/src/citra_qt/multiplayer/state.cpp b/src/citra_qt/multiplayer/state.cpp
new file mode 100644
index 000000000..dfd5ee66f
--- /dev/null
+++ b/src/citra_qt/multiplayer/state.cpp
@@ -0,0 +1,182 @@
+// Copyright 2018 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+#include
+#include
+#include
+#include
+#include "citra_qt/game_list.h"
+#include "citra_qt/multiplayer/client_room.h"
+#include "citra_qt/multiplayer/direct_connect.h"
+#include "citra_qt/multiplayer/host_room.h"
+#include "citra_qt/multiplayer/lobby.h"
+#include "citra_qt/multiplayer/message.h"
+#include "citra_qt/multiplayer/state.h"
+#include "citra_qt/util/clickable_label.h"
+#include "common/announce_multiplayer_room.h"
+#include "common/logging/log.h"
+
+MultiplayerState::MultiplayerState(QWidget* parent, QStandardItemModel* game_list_model,
+ QAction* leave_room, QAction* show_room)
+ : QWidget(parent), game_list_model(game_list_model), leave_room(leave_room),
+ show_room(show_room) {
+ if (auto member = Network::GetRoomMember().lock()) {
+ // register the network structs to use in slots and signals
+ state_callback_handle = member->BindOnStateChanged(
+ [this](const Network::RoomMember::State& state) { emit NetworkStateChanged(state); });
+ connect(this, &MultiplayerState::NetworkStateChanged, this,
+ &MultiplayerState::OnNetworkStateChanged);
+ }
+
+ qRegisterMetaType();
+ qRegisterMetaType();
+ announce_multiplayer_session = std::make_shared();
+ announce_multiplayer_session->BindErrorCallback(
+ [this](const Common::WebResult& result) { emit AnnounceFailed(result); });
+ connect(this, &MultiplayerState::AnnounceFailed, this, &MultiplayerState::OnAnnounceFailed);
+
+ status_text = new ClickableLabel(this);
+ status_icon = new ClickableLabel(this);
+ status_text->setToolTip(tr("Current connection status"));
+ status_text->setText(tr("Not Connected. Click here to find a room!"));
+ status_icon->setPixmap(QIcon::fromTheme("disconnected").pixmap(16));
+
+ connect(status_text, &ClickableLabel::clicked, this, &MultiplayerState::OnOpenNetworkRoom);
+ connect(status_icon, &ClickableLabel::clicked, this, &MultiplayerState::OnOpenNetworkRoom);
+}
+
+MultiplayerState::~MultiplayerState() {
+ if (state_callback_handle) {
+ if (auto member = Network::GetRoomMember().lock()) {
+ member->Unbind(state_callback_handle);
+ }
+ }
+}
+
+void MultiplayerState::Close() {
+ if (host_room)
+ host_room->close();
+ if (direct_connect)
+ direct_connect->close();
+ if (client_room)
+ client_room->close();
+ if (lobby)
+ lobby->close();
+}
+
+void MultiplayerState::OnNetworkStateChanged(const Network::RoomMember::State& state) {
+ NGLOG_DEBUG(Frontend, "Network State: {}", Network::GetStateStr(state));
+ bool is_connected = false;
+ switch (state) {
+ case Network::RoomMember::State::LostConnection:
+ NetworkMessage::ShowError(NetworkMessage::LOST_CONNECTION);
+ break;
+ case Network::RoomMember::State::CouldNotConnect:
+ NetworkMessage::ShowError(NetworkMessage::UNABLE_TO_CONNECT);
+ break;
+ case Network::RoomMember::State::NameCollision:
+ NetworkMessage::ShowError(NetworkMessage::USERNAME_IN_USE);
+ break;
+ case Network::RoomMember::State::MacCollision:
+ NetworkMessage::ShowError(NetworkMessage::MAC_COLLISION);
+ break;
+ case Network::RoomMember::State::WrongPassword:
+ NetworkMessage::ShowError(NetworkMessage::WRONG_PASSWORD);
+ break;
+ case Network::RoomMember::State::WrongVersion:
+ NetworkMessage::ShowError(NetworkMessage::WRONG_VERSION);
+ break;
+ case Network::RoomMember::State::Error:
+ NetworkMessage::ShowError(NetworkMessage::UNABLE_TO_CONNECT);
+ break;
+ case Network::RoomMember::State::Joined:
+ is_connected = true;
+ OnOpenNetworkRoom();
+ break;
+ }
+ if (is_connected) {
+ status_icon->setPixmap(QIcon::fromTheme("connected").pixmap(16));
+ status_text->setText(tr("Connected"));
+ leave_room->setEnabled(true);
+ show_room->setEnabled(true);
+ } else {
+ status_icon->setPixmap(QIcon::fromTheme("disconnected").pixmap(16));
+ status_text->setText(tr("Not Connected"));
+ leave_room->setEnabled(false);
+ show_room->setEnabled(false);
+ }
+}
+
+void MultiplayerState::OnAnnounceFailed(const Common::WebResult& result) {
+ announce_multiplayer_session->Stop();
+ QMessageBox::warning(
+ this, tr("Error"),
+ tr("Failed to announce the room to the public lobby. In order to host a room publicly, you "
+ "must have a valid Citra account configured in Emulation -> Configure -> Web. If you do "
+ "not want to publish a room in the public lobby, then select Unlisted instead.\n"
+ "Debug Message: ") +
+ QString::fromStdString(result.result_string),
+ QMessageBox::Ok);
+}
+
+static void BringWidgetToFront(QWidget* widget) {
+ widget->show();
+ widget->activateWindow();
+ widget->raise();
+}
+
+void MultiplayerState::OnViewLobby() {
+ if (lobby == nullptr) {
+ lobby = new Lobby(this, game_list_model, announce_multiplayer_session);
+ }
+ BringWidgetToFront(lobby);
+}
+
+void MultiplayerState::OnCreateRoom() {
+ if (host_room == nullptr) {
+ host_room = new HostRoomWindow(this, game_list_model, announce_multiplayer_session);
+ }
+ BringWidgetToFront(host_room);
+}
+
+bool MultiplayerState::OnCloseRoom() {
+ if (!NetworkMessage::WarnCloseRoom())
+ return false;
+ if (auto room = Network::GetRoom().lock()) {
+ // if you are in a room, leave it
+ if (auto member = Network::GetRoomMember().lock()) {
+ member->Leave();
+ }
+
+ // if you are hosting a room, also stop hosting
+ if (room->GetState() != Network::Room::State::Open) {
+ return true;
+ }
+ room->Destroy();
+ announce_multiplayer_session->Stop();
+ }
+ return true;
+}
+
+void MultiplayerState::OnOpenNetworkRoom() {
+ if (auto member = Network::GetRoomMember().lock()) {
+ if (member->IsConnected()) {
+ if (client_room == nullptr) {
+ client_room = new ClientRoomWindow(this);
+ }
+ BringWidgetToFront(client_room);
+ return;
+ }
+ }
+ // If the user is not a member of a room, show the lobby instead.
+ // This is currently only used on the clickable label in the status bar
+ OnViewLobby();
+}
+
+void MultiplayerState::OnDirectConnectToRoom() {
+ if (direct_connect == nullptr) {
+ direct_connect = new DirectConnectWindow(this);
+ }
+ BringWidgetToFront(direct_connect);
+}
diff --git a/src/citra_qt/multiplayer/state.h b/src/citra_qt/multiplayer/state.h
new file mode 100644
index 000000000..673bc6ecf
--- /dev/null
+++ b/src/citra_qt/multiplayer/state.h
@@ -0,0 +1,68 @@
+// Copyright 2018 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+#pragma once
+
+#include
+#include "network/network.h"
+
+class QStandardItemModel;
+class Lobby;
+class HostRoomWindow;
+class ClientRoomWindow;
+class DirectConnectWindow;
+class ClickableLabel;
+namespace Core {
+class AnnounceMultiplayerSession;
+}
+
+class MultiplayerState : public QWidget {
+ Q_OBJECT;
+
+public:
+ explicit MultiplayerState(QWidget* parent, QStandardItemModel* game_list, QAction* leave_room,
+ QAction* show_room);
+ ~MultiplayerState();
+
+ /**
+ * Close all open multiplayer related dialogs
+ */
+ void Close();
+
+ ClickableLabel* GetStatusText() const {
+ return status_text;
+ }
+
+ ClickableLabel* GetStatusIcon() const {
+ return status_icon;
+ }
+
+public slots:
+ void OnNetworkStateChanged(const Network::RoomMember::State& state);
+ void OnViewLobby();
+ void OnCreateRoom();
+ bool OnCloseRoom();
+ void OnOpenNetworkRoom();
+ void OnDirectConnectToRoom();
+ void OnAnnounceFailed(const Common::WebResult&);
+
+signals:
+ void NetworkStateChanged(const Network::RoomMember::State&);
+ void AnnounceFailed(const Common::WebResult&);
+
+private:
+ Lobby* lobby = nullptr;
+ HostRoomWindow* host_room = nullptr;
+ ClientRoomWindow* client_room = nullptr;
+ DirectConnectWindow* direct_connect = nullptr;
+ ClickableLabel* status_icon = nullptr;
+ ClickableLabel* status_text = nullptr;
+ QStandardItemModel* game_list_model = nullptr;
+ QAction* leave_room;
+ QAction* show_room;
+ std::shared_ptr announce_multiplayer_session;
+ Network::RoomMember::CallbackHandle state_callback_handle;
+};
+
+Q_DECLARE_METATYPE(Common::WebResult);
diff --git a/src/citra_qt/multiplayer/validation.h b/src/citra_qt/multiplayer/validation.h
new file mode 100644
index 000000000..4e8f6b9e9
--- /dev/null
+++ b/src/citra_qt/multiplayer/validation.h
@@ -0,0 +1,48 @@
+// Copyright 2017 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+#pragma once
+
+#include
+#include
+
+class Validation {
+public:
+ Validation()
+ : room_name(room_name_regex), nickname(nickname_regex), ip(ip_regex), port(0, 65535) {}
+
+ ~Validation() = default;
+
+ const QValidator* GetRoomName() const {
+ return &room_name;
+ }
+ const QValidator* GetNickname() const {
+ return &nickname;
+ }
+ const QValidator* GetIP() const {
+ return &ip;
+ }
+ const QValidator* GetPort() const {
+ return &port;
+ }
+
+private:
+ /// room name can be alphanumeric and " " "_" "." and "-"
+ QRegExp room_name_regex = QRegExp("^[a-zA-Z0-9._- ]+$");
+ QRegExpValidator room_name;
+
+ /// nickname can be alphanumeric and " " "_" "." and "-"
+ QRegExp nickname_regex = QRegExp("^[a-zA-Z0-9._- ]+$");
+ QRegExpValidator nickname;
+
+ /// ipv4 address only
+ // TODO remove this when we support hostnames in direct connect
+ QRegExp ip_regex = QRegExp(
+ "(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|"
+ "2[0-4][0-9]|25[0-5])");
+ QRegExpValidator ip;
+
+ /// port must be between 0 and 65535
+ QIntValidator port;
+};
diff --git a/src/citra_qt/ui_settings.h b/src/citra_qt/ui_settings.h
index caf6aea6a..b102f560d 100644
--- a/src/citra_qt/ui_settings.h
+++ b/src/citra_qt/ui_settings.h
@@ -56,8 +56,18 @@ struct Values {
std::vector shortcuts;
uint32_t callout_flags;
+
+ // multiplayer settings
+ QString nickname;
+ QString ip;
+ QString port;
+ QString room_nickname;
+ QString room_name;
+ quint32 max_player;
+ QString room_port;
+ uint host_type;
+ qulonglong game_id;
};
extern Values values;
-
} // namespace UISettings
diff --git a/src/citra_qt/util/clickable_label.cpp b/src/citra_qt/util/clickable_label.cpp
new file mode 100644
index 000000000..e990423a9
--- /dev/null
+++ b/src/citra_qt/util/clickable_label.cpp
@@ -0,0 +1,11 @@
+// Copyright 2017 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+#include "citra_qt/util/clickable_label.h"
+
+ClickableLabel::ClickableLabel(QWidget* parent, Qt::WindowFlags f) : QLabel(parent) {}
+
+void ClickableLabel::mouseReleaseEvent(QMouseEvent* event) {
+ emit clicked();
+}
diff --git a/src/citra_qt/util/clickable_label.h b/src/citra_qt/util/clickable_label.h
new file mode 100644
index 000000000..3c65a74be
--- /dev/null
+++ b/src/citra_qt/util/clickable_label.h
@@ -0,0 +1,22 @@
+// Copyright 2017 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+#pragma once
+
+#include
+#include
+
+class ClickableLabel : public QLabel {
+ Q_OBJECT
+
+public:
+ explicit ClickableLabel(QWidget* parent = nullptr, Qt::WindowFlags f = Qt::WindowFlags());
+ ~ClickableLabel() = default;
+
+signals:
+ void clicked();
+
+protected:
+ void mouseReleaseEvent(QMouseEvent* event);
+};
diff --git a/src/network/room_member.cpp b/src/network/room_member.cpp
index 3a143ce5d..e8d9094c5 100644
--- a/src/network/room_member.cpp
+++ b/src/network/room_member.cpp
@@ -406,6 +406,8 @@ void RoomMember::Join(const std::string& nick, const char* server_addr, u16 serv
ASSERT_MSG(room_member_impl->client != nullptr, "Could not create client");
}
+ room_member_impl->SetState(State::Joining);
+
ENetAddress address{};
enet_address_set_host(&address, server_addr);
address.port = server_port;
@@ -421,7 +423,6 @@ void RoomMember::Join(const std::string& nick, const char* server_addr, u16 serv
int net = enet_host_service(room_member_impl->client, &event, ConnectionTimeoutMs);
if (net > 0 && event.type == ENET_EVENT_TYPE_CONNECT) {
room_member_impl->nickname = nick;
- room_member_impl->SetState(State::Joining);
room_member_impl->StartLoop();
room_member_impl->SendJoinRequest(nick, preferred_mac, password);
SendGameInfo(room_member_impl->current_game_info);
diff --git a/src/network/room_member.h b/src/network/room_member.h
index 77b73890d..d01eb7dcd 100644
--- a/src/network/room_member.h
+++ b/src/network/room_member.h
@@ -187,4 +187,30 @@ private:
std::unique_ptr room_member_impl;
};
+static const char* GetStateStr(const RoomMember::State& s) {
+ switch (s) {
+ case RoomMember::State::Idle:
+ return "Idle";
+ case RoomMember::State::Error:
+ return "Error";
+ case RoomMember::State::Joining:
+ return "Joining";
+ case RoomMember::State::Joined:
+ return "Joined";
+ case RoomMember::State::LostConnection:
+ return "LostConnection";
+ case RoomMember::State::NameCollision:
+ return "NameCollision";
+ case RoomMember::State::MacCollision:
+ return "MacCollision";
+ case RoomMember::State::WrongVersion:
+ return "WrongVersion";
+ case RoomMember::State::WrongPassword:
+ return "WrongPassword";
+ case RoomMember::State::CouldNotConnect:
+ return "CouldNotConnect";
+ }
+ return "Unknown";
+}
+
} // namespace Network