mirror of
https://github.com/overte-org/overte.git
synced 2025-08-06 19:59:28 +02:00
Merge branch 'master' of https://github.com/highfidelity/hifi into blue
This commit is contained in:
commit
7a4ec35b99
8 changed files with 106 additions and 85 deletions
|
@ -384,18 +384,20 @@ void AvatarMixerSlave::broadcastAvatarData(const SharedNodePointer& node) {
|
||||||
if (includeThisAvatar) {
|
if (includeThisAvatar) {
|
||||||
numAvatarDataBytes += avatarPacketList->write(otherNode->getUUID().toRfc4122());
|
numAvatarDataBytes += avatarPacketList->write(otherNode->getUUID().toRfc4122());
|
||||||
numAvatarDataBytes += avatarPacketList->write(bytes);
|
numAvatarDataBytes += avatarPacketList->write(bytes);
|
||||||
_stats.numOthersIncluded++;
|
|
||||||
|
|
||||||
// increment the number of avatars sent to this reciever
|
if (detail != AvatarData::NoData) {
|
||||||
nodeData->incrementNumAvatarsSentLastFrame();
|
_stats.numOthersIncluded++;
|
||||||
|
|
||||||
// set the last sent sequence number for this sender on the receiver
|
// increment the number of avatars sent to this reciever
|
||||||
nodeData->setLastBroadcastSequenceNumber(otherNode->getUUID(),
|
nodeData->incrementNumAvatarsSentLastFrame();
|
||||||
otherNodeData->getLastReceivedSequenceNumber());
|
|
||||||
|
|
||||||
// remember the last time we sent details about this other node to the receiver
|
// set the last sent sequence number for this sender on the receiver
|
||||||
nodeData->setLastBroadcastTime(otherNode->getUUID(), start);
|
nodeData->setLastBroadcastSequenceNumber(otherNode->getUUID(),
|
||||||
|
otherNodeData->getLastReceivedSequenceNumber());
|
||||||
|
|
||||||
|
// remember the last time we sent details about this other node to the receiver
|
||||||
|
nodeData->setLastBroadcastTime(otherNode->getUUID(), start);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
avatarPacketList->endSegment();
|
avatarPacketList->endSegment();
|
||||||
|
|
|
@ -13,6 +13,7 @@
|
||||||
|
|
||||||
import QtQuick 2.5
|
import QtQuick 2.5
|
||||||
import QtQuick.Controls 1.4
|
import QtQuick.Controls 1.4
|
||||||
|
import Qt.labs.settings 1.0
|
||||||
import "../styles-uit"
|
import "../styles-uit"
|
||||||
import "../controls-uit" as HifiControls
|
import "../controls-uit" as HifiControls
|
||||||
|
|
||||||
|
@ -29,7 +30,9 @@ Rectangle {
|
||||||
property int myCardHeight: 90
|
property int myCardHeight: 90
|
||||||
property int rowHeight: 70
|
property int rowHeight: 70
|
||||||
property int actionButtonWidth: 55
|
property int actionButtonWidth: 55
|
||||||
property int nameCardWidth: palContainer.width - actionButtonWidth*(iAmAdmin ? 4 : 2) - 4 - hifi.dimensions.scrollbarBackgroundWidth
|
property int actionButtonAllowance: actionButtonWidth * 2
|
||||||
|
property int minNameCardWidth: palContainer.width - (actionButtonAllowance * 2) - 4 - hifi.dimensions.scrollbarBackgroundWidth
|
||||||
|
property int nameCardWidth: minNameCardWidth + (iAmAdmin ? 0 : actionButtonAllowance)
|
||||||
property var myData: ({displayName: "", userName: "", audioLevel: 0.0, admin: true}) // valid dummy until set
|
property var myData: ({displayName: "", userName: "", audioLevel: 0.0, admin: true}) // valid dummy until set
|
||||||
property var ignored: ({}); // Keep a local list of ignored avatars & their data. Necessary because HashMap is slow to respond after ignoring.
|
property var ignored: ({}); // Keep a local list of ignored avatars & their data. Necessary because HashMap is slow to respond after ignoring.
|
||||||
property var userModelData: [] // This simple list is essentially a mirror of the userModel listModel without all the extra complexities.
|
property var userModelData: [] // This simple list is essentially a mirror of the userModel listModel without all the extra complexities.
|
||||||
|
@ -52,6 +55,16 @@ Rectangle {
|
||||||
letterboxMessage.visible = true
|
letterboxMessage.visible = true
|
||||||
letterboxMessage.popupRadius = 0
|
letterboxMessage.popupRadius = 0
|
||||||
}
|
}
|
||||||
|
Settings {
|
||||||
|
id: settings
|
||||||
|
category: "pal"
|
||||||
|
property bool filtered: false
|
||||||
|
property int nearDistance: 30
|
||||||
|
}
|
||||||
|
function refreshWithFilter() {
|
||||||
|
// We should just be able to set settings.filtered to filter.checked, but see #3249, so send to .js for saving.
|
||||||
|
pal.sendToScript({method: 'refresh', params: {filter: filter.checked && {distance: settings.nearDistance}}});
|
||||||
|
}
|
||||||
|
|
||||||
// This is the container for the PAL
|
// This is the container for the PAL
|
||||||
Rectangle {
|
Rectangle {
|
||||||
|
@ -88,11 +101,32 @@ Rectangle {
|
||||||
audioLevel: myData.audioLevel
|
audioLevel: myData.audioLevel
|
||||||
isMyCard: true
|
isMyCard: true
|
||||||
// Size
|
// Size
|
||||||
width: nameCardWidth
|
width: minNameCardWidth
|
||||||
height: parent.height
|
height: parent.height
|
||||||
// Anchors
|
// Anchors
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
}
|
}
|
||||||
|
Row {
|
||||||
|
HifiControls.CheckBox {
|
||||||
|
id: filter
|
||||||
|
checked: settings.filtered
|
||||||
|
text: "in view"
|
||||||
|
boxSize: reload.height * 0.70
|
||||||
|
onCheckedChanged: refreshWithFilter()
|
||||||
|
}
|
||||||
|
HifiControls.GlyphButton {
|
||||||
|
id: reload
|
||||||
|
glyph: hifi.glyphs.reload
|
||||||
|
width: reload.height
|
||||||
|
onClicked: refreshWithFilter()
|
||||||
|
}
|
||||||
|
spacing: 50
|
||||||
|
anchors {
|
||||||
|
right: parent.right
|
||||||
|
top: parent.top
|
||||||
|
topMargin: 10
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Rectangles used to cover up rounded edges on bottom of MyInfo Rectangle
|
// Rectangles used to cover up rounded edges on bottom of MyInfo Rectangle
|
||||||
Rectangle {
|
Rectangle {
|
||||||
|
@ -306,45 +340,7 @@ Rectangle {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Refresh button
|
|
||||||
Rectangle {
|
|
||||||
// Size
|
|
||||||
width: hifi.dimensions.tableHeaderHeight-1
|
|
||||||
height: hifi.dimensions.tableHeaderHeight-1
|
|
||||||
// Anchors
|
|
||||||
anchors.left: table.left
|
|
||||||
anchors.leftMargin: 4
|
|
||||||
anchors.top: table.top
|
|
||||||
// Style
|
|
||||||
color: hifi.colors.tableBackgroundLight
|
|
||||||
// Actual refresh icon
|
|
||||||
HiFiGlyphs {
|
|
||||||
id: reloadButton
|
|
||||||
text: hifi.glyphs.reloadSmall
|
|
||||||
// Size
|
|
||||||
size: parent.width*1.5
|
|
||||||
// Anchors
|
|
||||||
anchors.fill: parent
|
|
||||||
// Style
|
|
||||||
horizontalAlignment: Text.AlignHCenter
|
|
||||||
color: hifi.colors.darkGray
|
|
||||||
}
|
|
||||||
MouseArea {
|
|
||||||
id: reloadButtonArea
|
|
||||||
// Anchors
|
|
||||||
anchors.fill: parent
|
|
||||||
hoverEnabled: true
|
|
||||||
// Everyone likes a responsive refresh button!
|
|
||||||
// So use onPressed instead of onClicked
|
|
||||||
onPressed: {
|
|
||||||
reloadButton.color = hifi.colors.lightGrayText
|
|
||||||
pal.sendToScript({method: 'refresh'})
|
|
||||||
}
|
|
||||||
onReleased: reloadButton.color = (containsMouse ? hifi.colors.baseGrayHighlight : hifi.colors.darkGray)
|
|
||||||
onEntered: reloadButton.color = hifi.colors.baseGrayHighlight
|
|
||||||
onExited: reloadButton.color = (pressed ? hifi.colors.lightGrayText: hifi.colors.darkGray)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Separator between user and admin functions
|
// Separator between user and admin functions
|
||||||
Rectangle {
|
Rectangle {
|
||||||
// Size
|
// Size
|
||||||
|
@ -501,7 +497,7 @@ Rectangle {
|
||||||
if (alreadyRefreshed === true) {
|
if (alreadyRefreshed === true) {
|
||||||
letterbox('', '', 'The last editor of this object is either you or not among this list of users.');
|
letterbox('', '', 'The last editor of this object is either you or not among this list of users.');
|
||||||
} else {
|
} else {
|
||||||
pal.sendToScript({method: 'refresh', params: message.params});
|
pal.sendToScript({method: 'refresh', params: {selected: message.params}});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// If we've already refreshed the PAL and found the avatar in the model
|
// If we've already refreshed the PAL and found the avatar in the model
|
||||||
|
|
|
@ -192,6 +192,8 @@ QVariantMap Camera::getViewFrustum() {
|
||||||
result["orientation"].setValue(frustum.getOrientation());
|
result["orientation"].setValue(frustum.getOrientation());
|
||||||
result["projection"].setValue(frustum.getProjection());
|
result["projection"].setValue(frustum.getProjection());
|
||||||
result["centerRadius"].setValue(frustum.getCenterRadius());
|
result["centerRadius"].setValue(frustum.getCenterRadius());
|
||||||
|
result["fieldOfView"].setValue(frustum.getFieldOfView());
|
||||||
|
result["aspectRatio"].setValue(frustum.getAspectRatio());
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
|
@ -85,7 +85,7 @@ AvatarManager::AvatarManager(QObject* parent) :
|
||||||
// immediately remove that avatar instead of waiting for the absence of packets from avatar mixer
|
// immediately remove that avatar instead of waiting for the absence of packets from avatar mixer
|
||||||
connect(nodeList.data(), &NodeList::ignoredNode, this, [=](const QUuid& nodeID, bool enabled) {
|
connect(nodeList.data(), &NodeList::ignoredNode, this, [=](const QUuid& nodeID, bool enabled) {
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
removeAvatar(nodeID);
|
removeAvatar(nodeID, KillAvatarReason::AvatarIgnored);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -182,7 +182,7 @@ void AvatarHashMap::removeAvatar(const QUuid& sessionUUID, KillAvatarReason remo
|
||||||
|
|
||||||
void AvatarHashMap::handleRemovedAvatar(const AvatarSharedPointer& removedAvatar, KillAvatarReason removalReason) {
|
void AvatarHashMap::handleRemovedAvatar(const AvatarSharedPointer& removedAvatar, KillAvatarReason removalReason) {
|
||||||
qCDebug(avatars) << "Removed avatar with UUID" << uuidStringWithoutCurlyBraces(removedAvatar->getSessionUUID())
|
qCDebug(avatars) << "Removed avatar with UUID" << uuidStringWithoutCurlyBraces(removedAvatar->getSessionUUID())
|
||||||
<< "from AvatarHashMap";
|
<< "from AvatarHashMap" << removalReason;
|
||||||
emit avatarRemovedEvent(removedAvatar->getSessionUUID());
|
emit avatarRemovedEvent(removedAvatar->getSessionUUID());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -150,7 +150,6 @@ signals:
|
||||||
private:
|
private:
|
||||||
bool getRequestsDomainListData();
|
bool getRequestsDomainListData();
|
||||||
void setRequestsDomainListData(bool requests);
|
void setRequestsDomainListData(bool requests);
|
||||||
bool _requestsDomainListData;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -20,7 +20,7 @@ var DEFAULT_SCRIPTS = [
|
||||||
"system/bubble.js",
|
"system/bubble.js",
|
||||||
"system/snapshot.js",
|
"system/snapshot.js",
|
||||||
"system/help.js",
|
"system/help.js",
|
||||||
"system/pal.js", //"system/mod.js", // older UX, if you prefer
|
"system/pal.js", // "system/mod.js", // older UX, if you prefer
|
||||||
"system/goto.js",
|
"system/goto.js",
|
||||||
"system/marketplaces/marketplaces.js",
|
"system/marketplaces/marketplaces.js",
|
||||||
"system/edit.js",
|
"system/edit.js",
|
||||||
|
@ -54,9 +54,6 @@ if (previousSetting === true || previousSetting === 'true') {
|
||||||
previousSetting = true;
|
previousSetting = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (Menu.menuExists(MENU_CATEGORY) && !Menu.menuItemExists(MENU_CATEGORY, MENU_ITEM)) {
|
if (Menu.menuExists(MENU_CATEGORY) && !Menu.menuItemExists(MENU_CATEGORY, MENU_ITEM)) {
|
||||||
Menu.addMenuItem({
|
Menu.addMenuItem({
|
||||||
menuName: MENU_CATEGORY,
|
menuName: MENU_CATEGORY,
|
||||||
|
@ -78,11 +75,11 @@ function runDefaultsSeparately() {
|
||||||
Script.load(DEFAULT_SCRIPTS[i]);
|
Script.load(DEFAULT_SCRIPTS[i]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// start all scripts
|
// start all scripts
|
||||||
if (Menu.isOptionChecked(MENU_ITEM)) {
|
if (Menu.isOptionChecked(MENU_ITEM)) {
|
||||||
// we're debugging individual default scripts
|
// we're debugging individual default scripts
|
||||||
// so we load each into its own ScriptEngine instance
|
// so we load each into its own ScriptEngine instance
|
||||||
debuggingDefaultScripts = true;
|
|
||||||
runDefaultsSeparately();
|
runDefaultsSeparately();
|
||||||
} else {
|
} else {
|
||||||
// include all default scripts into this ScriptEngine
|
// include all default scripts into this ScriptEngine
|
||||||
|
@ -90,32 +87,14 @@ if (Menu.isOptionChecked(MENU_ITEM)) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function menuItemEvent(menuItem) {
|
function menuItemEvent(menuItem) {
|
||||||
if (menuItem == MENU_ITEM) {
|
if (menuItem === MENU_ITEM) {
|
||||||
|
var isChecked = Menu.isOptionChecked(MENU_ITEM);
|
||||||
isChecked = Menu.isOptionChecked(MENU_ITEM);
|
|
||||||
if (isChecked === true) {
|
if (isChecked === true) {
|
||||||
Settings.setValue(SETTINGS_KEY, true);
|
Settings.setValue(SETTINGS_KEY, true);
|
||||||
} else if (isChecked === false) {
|
} else if (isChecked === false) {
|
||||||
Settings.setValue(SETTINGS_KEY, false);
|
Settings.setValue(SETTINGS_KEY, false);
|
||||||
}
|
}
|
||||||
Window.alert('You must reload all scripts for this to take effect.')
|
Menu.triggerOption("Reload All Scripts");
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function stopLoadedScripts() {
|
|
||||||
// remove debug script loads
|
|
||||||
var runningScripts = ScriptDiscoveryService.getRunning();
|
|
||||||
for (var i in runningScripts) {
|
|
||||||
var scriptName = runningScripts[i].name;
|
|
||||||
for (var j in DEFAULT_SCRIPTS) {
|
|
||||||
if (DEFAULT_SCRIPTS[j].slice(-scriptName.length) === scriptName) {
|
|
||||||
ScriptDiscoveryService.stopScript(runningScripts[i].url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -126,7 +105,6 @@ function removeMenuItem() {
|
||||||
}
|
}
|
||||||
|
|
||||||
Script.scriptEnding.connect(function() {
|
Script.scriptEnding.connect(function() {
|
||||||
stopLoadedScripts();
|
|
||||||
removeMenuItem();
|
removeMenuItem();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -37,6 +37,15 @@ var conserveResources = true;
|
||||||
|
|
||||||
Script.include("/~/system/libraries/controllers.js");
|
Script.include("/~/system/libraries/controllers.js");
|
||||||
|
|
||||||
|
function projectVectorOntoPlane(normalizedVector, planeNormal) {
|
||||||
|
return Vec3.cross(planeNormal, Vec3.cross(normalizedVector, planeNormal));
|
||||||
|
}
|
||||||
|
function angleBetweenVectorsInPlane(from, to, normal) {
|
||||||
|
var projectedFrom = projectVectorOntoPlane(from, normal);
|
||||||
|
var projectedTo = projectVectorOntoPlane(to, normal);
|
||||||
|
return Vec3.orientedAngle(projectedFrom, projectedTo, normal);
|
||||||
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
// Overlays.
|
// Overlays.
|
||||||
//
|
//
|
||||||
|
@ -230,7 +239,11 @@ function fromQml(message) { // messages are {method, params}, like json-rpc. See
|
||||||
break;
|
break;
|
||||||
case 'refresh':
|
case 'refresh':
|
||||||
removeOverlays();
|
removeOverlays();
|
||||||
populateUserList(message.params);
|
// If filter is specified from .qml instead of through settings, update the settings.
|
||||||
|
if (message.params.filter !== undefined) {
|
||||||
|
Settings.setValue('pal/filtered', !!message.params.filter);
|
||||||
|
}
|
||||||
|
populateUserList(message.params.selected);
|
||||||
UserActivityLogger.palAction("refresh", "");
|
UserActivityLogger.palAction("refresh", "");
|
||||||
break;
|
break;
|
||||||
case 'updateGain':
|
case 'updateGain':
|
||||||
|
@ -272,13 +285,42 @@ function addAvatarNode(id) {
|
||||||
color: color(selected, false, 0.0),
|
color: color(selected, false, 0.0),
|
||||||
ignoreRayIntersection: false}, selected, !conserveResources);
|
ignoreRayIntersection: false}, selected, !conserveResources);
|
||||||
}
|
}
|
||||||
|
// Each open/refresh will capture a stable set of avatarsOfInterest, within the specified filter.
|
||||||
|
var avatarsOfInterest = {};
|
||||||
function populateUserList(selectData) {
|
function populateUserList(selectData) {
|
||||||
|
var filter = Settings.getValue('pal/filtered') && {distance: Settings.getValue('pal/nearDistance')};
|
||||||
var data = [], avatars = AvatarList.getAvatarIdentifiers();
|
var data = [], avatars = AvatarList.getAvatarIdentifiers();
|
||||||
conserveResources = avatars.length > 20;
|
avatarsOfInterest = {};
|
||||||
|
var myPosition = filter && Camera.position,
|
||||||
|
frustum = filter && Camera.frustum,
|
||||||
|
verticalHalfAngle = filter && (frustum.fieldOfView / 2),
|
||||||
|
horizontalHalfAngle = filter && (verticalHalfAngle * frustum.aspectRatio),
|
||||||
|
orientation = filter && Camera.orientation,
|
||||||
|
front = filter && Quat.getFront(orientation),
|
||||||
|
verticalAngleNormal = filter && Quat.getRight(orientation),
|
||||||
|
horizontalAngleNormal = filter && Quat.getUp(orientation);
|
||||||
avatars.forEach(function (id) { // sorting the identifiers is just an aid for debugging
|
avatars.forEach(function (id) { // sorting the identifiers is just an aid for debugging
|
||||||
var avatar = AvatarList.getAvatar(id);
|
var avatar = AvatarList.getAvatar(id);
|
||||||
|
var name = avatar.sessionDisplayName;
|
||||||
|
if (!name) {
|
||||||
|
// Either we got a data packet but no identity yet, or something is really messed up. In any case,
|
||||||
|
// we won't be able to do anything with this user, so don't include them.
|
||||||
|
// In normal circumstances, a refresh will bring in the new user, but if we're very heavily loaded,
|
||||||
|
// we could be losing and gaining people randomly.
|
||||||
|
print('No avatar identity data for', id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (id && myPosition && (Vec3.distance(avatar.position, myPosition) > filter.distance)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var normal = id && filter && Vec3.normalize(Vec3.subtract(avatar.position, myPosition));
|
||||||
|
var horizontal = normal && angleBetweenVectorsInPlane(normal, front, horizontalAngleNormal);
|
||||||
|
var vertical = normal && angleBetweenVectorsInPlane(normal, front, verticalAngleNormal);
|
||||||
|
if (id && filter && ((Math.abs(horizontal) > horizontalHalfAngle) || (Math.abs(vertical) > verticalHalfAngle))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
var avatarPalDatum = {
|
var avatarPalDatum = {
|
||||||
displayName: avatar.sessionDisplayName,
|
displayName: name,
|
||||||
userName: '',
|
userName: '',
|
||||||
sessionId: id || '',
|
sessionId: id || '',
|
||||||
audioLevel: 0.0,
|
audioLevel: 0.0,
|
||||||
|
@ -290,10 +332,12 @@ function populateUserList(selectData) {
|
||||||
addAvatarNode(id); // No overlay for ourselves
|
addAvatarNode(id); // No overlay for ourselves
|
||||||
// Everyone needs to see admin status. Username and fingerprint returns default constructor output if the requesting user isn't an admin.
|
// Everyone needs to see admin status. Username and fingerprint returns default constructor output if the requesting user isn't an admin.
|
||||||
Users.requestUsernameFromID(id);
|
Users.requestUsernameFromID(id);
|
||||||
|
avatarsOfInterest[id] = true;
|
||||||
}
|
}
|
||||||
data.push(avatarPalDatum);
|
data.push(avatarPalDatum);
|
||||||
print('PAL data:', JSON.stringify(avatarPalDatum));
|
print('PAL data:', JSON.stringify(avatarPalDatum));
|
||||||
});
|
});
|
||||||
|
conserveResources = Object.keys(avatarsOfInterest).length > 20;
|
||||||
sendToQml({ method: 'users', params: data });
|
sendToQml({ method: 'users', params: data });
|
||||||
if (selectData) {
|
if (selectData) {
|
||||||
selectData[2] = true;
|
selectData[2] = true;
|
||||||
|
@ -318,8 +362,8 @@ var pingPong = true;
|
||||||
function updateOverlays() {
|
function updateOverlays() {
|
||||||
var eye = Camera.position;
|
var eye = Camera.position;
|
||||||
AvatarList.getAvatarIdentifiers().forEach(function (id) {
|
AvatarList.getAvatarIdentifiers().forEach(function (id) {
|
||||||
if (!id) {
|
if (!id || !avatarsOfInterest[id]) {
|
||||||
return; // don't update ourself
|
return; // don't update ourself, or avatars we're not interested in
|
||||||
}
|
}
|
||||||
var avatar = AvatarList.getAvatar(id);
|
var avatar = AvatarList.getAvatar(id);
|
||||||
if (!avatar) {
|
if (!avatar) {
|
||||||
|
|
Loading…
Reference in a new issue