mirror of
https://github.com/hensm/fx_cast.git
synced 2026-06-08 08:39:59 +00:00
Move selectorManager getSelection to ReceiverSelector static method
This commit is contained in:
@@ -3,9 +3,9 @@
|
||||
import logger from "../lib/logger";
|
||||
import messaging, { Port, Message } from "../messaging";
|
||||
import options from "../lib/options";
|
||||
|
||||
import { TypedEventTarget } from "../lib/TypedEventTarget";
|
||||
import { SenderMediaMessage, SenderMessage } from "../cast/sdk/types";
|
||||
import { getMediaTypesForPageUrl } from "../lib/utils";
|
||||
|
||||
import {
|
||||
ReceiverDevice,
|
||||
ReceiverSelectionActionType,
|
||||
@@ -13,6 +13,13 @@ import {
|
||||
ReceiverSelectorPageInfo
|
||||
} from "../types";
|
||||
|
||||
import deviceManager from "./deviceManager";
|
||||
import castManager from "./castManager";
|
||||
|
||||
import { BaseConfig, baseConfigStorage, getAppTag } from "../cast/googleApi";
|
||||
import type { SessionRequest } from "../cast/sdk/classes";
|
||||
import type { SenderMediaMessage, SenderMessage } from "../cast/sdk/types";
|
||||
|
||||
const POPUP_URL = browser.runtime.getURL("ui/popup/index.html");
|
||||
|
||||
export interface ReceiverSelectionCast {
|
||||
@@ -45,6 +52,16 @@ interface ReceiverSelectorEvents {
|
||||
mediaMessage: ReceiverSelectorMediaMessage;
|
||||
}
|
||||
|
||||
let baseConfig: BaseConfig;
|
||||
baseConfigStorage
|
||||
.get("baseConfig")
|
||||
.then(value => {
|
||||
baseConfig = value.baseConfig;
|
||||
})
|
||||
.catch(() => {
|
||||
logger.error("Failed to get Chromecast base config!");
|
||||
});
|
||||
|
||||
/**
|
||||
* Manages the receiver selector popup window and communication with the
|
||||
* extension page hosted within.
|
||||
@@ -316,4 +333,249 @@ export default class ReceiverSelector extends TypedEventTarget<ReceiverSelectorE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static shared = new ReceiverSelector();
|
||||
|
||||
/**
|
||||
* Opens a receiver selector with the specified default/available media
|
||||
* types.
|
||||
*
|
||||
* Returns a promise that:
|
||||
* - Resolves to a ReceiverSelection object if selection is
|
||||
* successful.
|
||||
* - Resolves to null if the selection is cancelled.
|
||||
* - Rejects if the selection fails.
|
||||
*/
|
||||
static getSelection(
|
||||
contextTabId: number,
|
||||
contextFrameId = 0,
|
||||
selectionOpts?: {
|
||||
sessionRequest?: SessionRequest;
|
||||
withMediaSender?: boolean;
|
||||
}
|
||||
): Promise<ReceiverSelection | null> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
let castInstance = castManager.getInstance(
|
||||
contextTabId,
|
||||
contextFrameId
|
||||
);
|
||||
|
||||
/**
|
||||
* If the current context is running the mirroring app, pretend
|
||||
* it doesn't exist because it shouldn't be launched like this.
|
||||
*/
|
||||
if (castInstance?.appId === (await options.get("mirroringAppId"))) {
|
||||
castInstance = undefined;
|
||||
}
|
||||
|
||||
let defaultMediaType = ReceiverSelectorMediaType.Tab;
|
||||
let availableMediaTypes = ReceiverSelectorMediaType.None;
|
||||
|
||||
let pageUrl: string | undefined;
|
||||
try {
|
||||
pageUrl = (
|
||||
await browser.webNavigation.getFrame({
|
||||
tabId: contextTabId,
|
||||
frameId: contextFrameId
|
||||
})
|
||||
).url;
|
||||
|
||||
availableMediaTypes = getMediaTypesForPageUrl(pageUrl);
|
||||
} catch {
|
||||
logger.error(
|
||||
"Failed to locate frame, falling back to default available media types."
|
||||
);
|
||||
}
|
||||
|
||||
// Enable app media type if sender application is present
|
||||
if (castInstance || selectionOpts?.withMediaSender) {
|
||||
defaultMediaType = ReceiverSelectorMediaType.App;
|
||||
availableMediaTypes |= ReceiverSelectorMediaType.App;
|
||||
}
|
||||
|
||||
const opts = await options.getAll();
|
||||
|
||||
// Disable mirroring media types if mirroring is not enabled
|
||||
if (!opts.mirroringEnabled) {
|
||||
availableMediaTypes &= ~(
|
||||
ReceiverSelectorMediaType.Tab |
|
||||
ReceiverSelectorMediaType.Screen
|
||||
);
|
||||
}
|
||||
|
||||
// Remove file media type if local media is not enabled
|
||||
if (!opts.mediaEnabled || !opts.localMediaEnabled) {
|
||||
availableMediaTypes &= ~ReceiverSelectorMediaType.File;
|
||||
}
|
||||
|
||||
// Close an existing open selector
|
||||
if (ReceiverSelector.shared && ReceiverSelector.shared.isOpen) {
|
||||
ReceiverSelector.shared.close();
|
||||
}
|
||||
|
||||
// Get a new selector for each selection
|
||||
ReceiverSelector.shared = new ReceiverSelector();
|
||||
|
||||
function onReceiverChange() {
|
||||
ReceiverSelector.shared.update(deviceManager.getDevices());
|
||||
}
|
||||
|
||||
deviceManager.addEventListener(
|
||||
"receiverDeviceUp",
|
||||
onReceiverChange
|
||||
);
|
||||
deviceManager.addEventListener(
|
||||
"receiverDeviceDown",
|
||||
onReceiverChange
|
||||
);
|
||||
deviceManager.addEventListener(
|
||||
"receiverDeviceUpdated",
|
||||
onReceiverChange
|
||||
);
|
||||
deviceManager.addEventListener(
|
||||
"receiverDeviceMediaUpdated",
|
||||
onReceiverChange
|
||||
);
|
||||
|
||||
function onSelectorSelected(
|
||||
ev: CustomEvent<ReceiverSelectionCast>
|
||||
) {
|
||||
logger.info("Selected receiver", ev.detail);
|
||||
|
||||
resolve({
|
||||
actionType: ReceiverSelectionActionType.Cast,
|
||||
receiverDevice: ev.detail.receiverDevice,
|
||||
mediaType: ev.detail.mediaType
|
||||
});
|
||||
}
|
||||
function onSelectorStop(ev: CustomEvent<ReceiverSelectionStop>) {
|
||||
logger.info("Stopping receiver app...", ev.detail);
|
||||
|
||||
deviceManager.stopReceiverApp(ev.detail.receiverDevice.id);
|
||||
|
||||
resolve({
|
||||
actionType: ReceiverSelectionActionType.Stop,
|
||||
receiverDevice: ev.detail.receiverDevice
|
||||
});
|
||||
}
|
||||
function onSelectorCancelled() {
|
||||
logger.info("Cancelled receiver selection");
|
||||
|
||||
resolve(null);
|
||||
}
|
||||
function onSelectorError(ev: CustomEvent<string>) {
|
||||
reject(ev.detail);
|
||||
}
|
||||
function onReceiverMessage(
|
||||
ev: CustomEvent<ReceiverSelectorReceiverMessage>
|
||||
) {
|
||||
deviceManager.sendReceiverMessage(
|
||||
ev.detail.deviceId,
|
||||
ev.detail.message
|
||||
);
|
||||
}
|
||||
function onMediaMessage(
|
||||
ev: CustomEvent<ReceiverSelectorMediaMessage>
|
||||
) {
|
||||
deviceManager.sendMediaMessage(
|
||||
ev.detail.deviceId,
|
||||
ev.detail.message
|
||||
);
|
||||
}
|
||||
|
||||
ReceiverSelector.shared.addEventListener(
|
||||
"selected",
|
||||
onSelectorSelected
|
||||
);
|
||||
ReceiverSelector.shared.addEventListener("stop", onSelectorStop);
|
||||
ReceiverSelector.shared.addEventListener(
|
||||
"cancelled",
|
||||
onSelectorCancelled
|
||||
);
|
||||
ReceiverSelector.shared.addEventListener("error", onSelectorError);
|
||||
ReceiverSelector.shared.addEventListener(
|
||||
"receiverMessage",
|
||||
onReceiverMessage
|
||||
);
|
||||
ReceiverSelector.shared.addEventListener(
|
||||
"mediaMessage",
|
||||
onMediaMessage
|
||||
);
|
||||
ReceiverSelector.shared.addEventListener("close", removeListeners);
|
||||
|
||||
function removeListeners() {
|
||||
ReceiverSelector.shared.removeEventListener(
|
||||
"selected",
|
||||
onSelectorSelected
|
||||
);
|
||||
ReceiverSelector.shared.removeEventListener(
|
||||
"stop",
|
||||
onSelectorStop
|
||||
);
|
||||
ReceiverSelector.shared.removeEventListener(
|
||||
"cancelled",
|
||||
onSelectorCancelled
|
||||
);
|
||||
ReceiverSelector.shared.removeEventListener(
|
||||
"error",
|
||||
onSelectorError
|
||||
);
|
||||
ReceiverSelector.shared.removeEventListener(
|
||||
"receiverMessage",
|
||||
onReceiverMessage
|
||||
);
|
||||
ReceiverSelector.shared.removeEventListener(
|
||||
"mediaMessage",
|
||||
onMediaMessage
|
||||
);
|
||||
ReceiverSelector.shared.removeEventListener(
|
||||
"close",
|
||||
removeListeners
|
||||
);
|
||||
|
||||
deviceManager.removeEventListener(
|
||||
"receiverDeviceUp",
|
||||
onReceiverChange
|
||||
);
|
||||
deviceManager.removeEventListener(
|
||||
"receiverDeviceDown",
|
||||
onReceiverChange
|
||||
);
|
||||
deviceManager.removeEventListener(
|
||||
"receiverDeviceUpdated",
|
||||
onReceiverChange
|
||||
);
|
||||
deviceManager.removeEventListener(
|
||||
"receiverDeviceMediaUpdated",
|
||||
onReceiverChange
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure status manager is initialized
|
||||
await deviceManager.init();
|
||||
|
||||
let isRequestAppAudioCompatible: Optional<boolean>;
|
||||
if (castInstance?.appId) {
|
||||
const appTag = getAppTag(baseConfig, castInstance.appId);
|
||||
isRequestAppAudioCompatible = appTag?.supports_audio_only;
|
||||
}
|
||||
|
||||
ReceiverSelector.shared.open({
|
||||
receiverDevices: deviceManager.getDevices(),
|
||||
defaultMediaType,
|
||||
availableMediaTypes,
|
||||
appId: castInstance?.appId,
|
||||
// Create page info
|
||||
pageInfo: pageUrl
|
||||
? {
|
||||
url: pageUrl,
|
||||
tabId: contextTabId,
|
||||
frameId: contextFrameId,
|
||||
sessionRequest: selectionOpts?.sessionRequest,
|
||||
isRequestAppAudioCompatible
|
||||
}
|
||||
: undefined
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,11 +7,11 @@ import bridge, { BridgeInfo } from "../lib/bridge";
|
||||
|
||||
import castManager from "./castManager";
|
||||
import deviceManager from "./deviceManager";
|
||||
import selectorManager from "./selectorManager";
|
||||
import ReceiverSelector from "./ReceiverSelector";
|
||||
|
||||
import { initMenus } from "./menus";
|
||||
import { initWhitelist } from "./whitelist";
|
||||
import { baseConfigStorage, fetchBaseConfig } from "../cast/googleapi";
|
||||
import { baseConfigStorage, fetchBaseConfig } from "../cast/googleApi";
|
||||
|
||||
const _ = browser.i18n.getMessage;
|
||||
|
||||
@@ -145,7 +145,7 @@ async function init() {
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = await selectorManager.getSelection(tab.id);
|
||||
const selection = await ReceiverSelector.getSelection(tab.id);
|
||||
if (selection) {
|
||||
castManager.loadSender({
|
||||
tabId: tab.id,
|
||||
|
||||
@@ -11,10 +11,8 @@ import {
|
||||
ReceiverSelectorMediaType
|
||||
} from "../types";
|
||||
|
||||
import { ReceiverSelection } from "./ReceiverSelector";
|
||||
|
||||
import deviceManager from "./deviceManager";
|
||||
import selectorManager from "./selectorManager";
|
||||
import ReceiverSelector, { ReceiverSelection } from "./ReceiverSelector";
|
||||
|
||||
type AnyPort = Port | MessagePort;
|
||||
|
||||
@@ -225,7 +223,7 @@ export default new (class {
|
||||
}
|
||||
|
||||
try {
|
||||
const selection = await selectorManager.getSelection(
|
||||
const selection = await ReceiverSelector.getSelection(
|
||||
instance.contentTabId,
|
||||
instance.contentFrameId,
|
||||
{ sessionRequest: message.data.sessionRequest }
|
||||
@@ -297,7 +295,7 @@ export default new (class {
|
||||
* same one that caused the session creation.
|
||||
*/
|
||||
case "main:closeReceiverSelector": {
|
||||
const selector = await selectorManager.getSelector();
|
||||
const selector = ReceiverSelector.shared;
|
||||
const shouldClose = await options.get(
|
||||
"receiverSelectorWaitForConnection"
|
||||
);
|
||||
|
||||
@@ -9,9 +9,7 @@ import {
|
||||
ReceiverSelectorMediaType
|
||||
} from "../types";
|
||||
|
||||
import { ReceiverSelection } from "./ReceiverSelector";
|
||||
|
||||
import selectorManager from "./selectorManager";
|
||||
import ReceiverSelector, { ReceiverSelection } from "./ReceiverSelector";
|
||||
import castManager from "./castManager";
|
||||
|
||||
const _ = browser.i18n.getMessage;
|
||||
@@ -157,10 +155,12 @@ async function onMenuClicked(
|
||||
|
||||
let selection: Nullable<ReceiverSelection> = null;
|
||||
try {
|
||||
selection = await selectorManager.getSelection(
|
||||
selection = await ReceiverSelector.getSelection(
|
||||
tab.id,
|
||||
info.frameId,
|
||||
{ withMediaSender: castMediaMenuClicked }
|
||||
{
|
||||
withMediaSender: castMediaMenuClicked
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error("Failed to get receiver selection (cast menu)", err);
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
import options from "../lib/options";
|
||||
import logger from "../lib/logger";
|
||||
|
||||
import { getMediaTypesForPageUrl } from "../lib/utils";
|
||||
import { BaseConfig, baseConfigStorage, getAppTag } from "../cast/googleapi";
|
||||
import { SessionRequest } from "../cast/sdk/classes";
|
||||
|
||||
import castManager from "./castManager";
|
||||
import deviceManager from "./deviceManager";
|
||||
|
||||
import ReceiverSelector, {
|
||||
ReceiverSelection,
|
||||
ReceiverSelectionCast,
|
||||
ReceiverSelectionStop,
|
||||
ReceiverSelectorMediaMessage,
|
||||
ReceiverSelectorReceiverMessage
|
||||
} from "./ReceiverSelector";
|
||||
|
||||
import {
|
||||
ReceiverSelectionActionType,
|
||||
ReceiverSelectorMediaType
|
||||
} from "../types";
|
||||
|
||||
let baseConfig: BaseConfig;
|
||||
baseConfigStorage
|
||||
.get("baseConfig")
|
||||
.then(value => {
|
||||
baseConfig = value.baseConfig;
|
||||
})
|
||||
.catch(() => {
|
||||
logger.error("Failed to get Chromecast base config!");
|
||||
});
|
||||
|
||||
let sharedSelector: ReceiverSelector;
|
||||
async function getSelector() {
|
||||
if (!sharedSelector) {
|
||||
try {
|
||||
sharedSelector = new ReceiverSelector();
|
||||
} catch (err) {
|
||||
throw logger.error("Failed to create receiver selector.");
|
||||
}
|
||||
}
|
||||
|
||||
return sharedSelector;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a receiver selector with the specified default/available media
|
||||
* types.
|
||||
*
|
||||
* Returns a promise that:
|
||||
* - Resolves to a ReceiverSelection object if selection is
|
||||
* successful.
|
||||
* - Resolves to null if the selection is cancelled.
|
||||
* - Rejects if the selection fails.
|
||||
*/
|
||||
async function getSelection(
|
||||
contextTabId: number,
|
||||
contextFrameId = 0,
|
||||
selectionOpts?: {
|
||||
sessionRequest?: SessionRequest;
|
||||
withMediaSender?: boolean;
|
||||
}
|
||||
): Promise<ReceiverSelection | null> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
let castInstance = castManager.getInstance(
|
||||
contextTabId,
|
||||
contextFrameId
|
||||
);
|
||||
|
||||
/**
|
||||
* If the current context is running the mirroring app, pretend
|
||||
* it doesn't exist because it shouldn't be launched like this.
|
||||
*/
|
||||
if (castInstance?.appId === (await options.get("mirroringAppId"))) {
|
||||
castInstance = undefined;
|
||||
}
|
||||
|
||||
let defaultMediaType = ReceiverSelectorMediaType.Tab;
|
||||
let availableMediaTypes = ReceiverSelectorMediaType.None;
|
||||
|
||||
let pageUrl: string | undefined;
|
||||
try {
|
||||
pageUrl = (
|
||||
await browser.webNavigation.getFrame({
|
||||
tabId: contextTabId,
|
||||
frameId: contextFrameId
|
||||
})
|
||||
).url;
|
||||
|
||||
availableMediaTypes = getMediaTypesForPageUrl(pageUrl);
|
||||
} catch {
|
||||
logger.error(
|
||||
"Failed to locate frame, falling back to default available media types."
|
||||
);
|
||||
}
|
||||
|
||||
// Enable app media type if sender application is present
|
||||
if (castInstance || selectionOpts?.withMediaSender) {
|
||||
defaultMediaType = ReceiverSelectorMediaType.App;
|
||||
availableMediaTypes |= ReceiverSelectorMediaType.App;
|
||||
}
|
||||
|
||||
const opts = await options.getAll();
|
||||
|
||||
// Disable mirroring media types if mirroring is not enabled
|
||||
if (!opts.mirroringEnabled) {
|
||||
availableMediaTypes &= ~(
|
||||
ReceiverSelectorMediaType.Tab | ReceiverSelectorMediaType.Screen
|
||||
);
|
||||
}
|
||||
|
||||
// Remove file media type if local media is not enabled
|
||||
if (!opts.mediaEnabled || !opts.localMediaEnabled) {
|
||||
availableMediaTypes &= ~ReceiverSelectorMediaType.File;
|
||||
}
|
||||
|
||||
// Close an existing open selector
|
||||
if (sharedSelector && sharedSelector.isOpen) {
|
||||
sharedSelector.close();
|
||||
}
|
||||
|
||||
// Get a new selector for each selection
|
||||
sharedSelector = new ReceiverSelector();
|
||||
|
||||
function onReceiverChange() {
|
||||
sharedSelector.update(deviceManager.getDevices());
|
||||
}
|
||||
|
||||
deviceManager.addEventListener("receiverDeviceUp", onReceiverChange);
|
||||
deviceManager.addEventListener("receiverDeviceDown", onReceiverChange);
|
||||
deviceManager.addEventListener(
|
||||
"receiverDeviceUpdated",
|
||||
onReceiverChange
|
||||
);
|
||||
deviceManager.addEventListener(
|
||||
"receiverDeviceMediaUpdated",
|
||||
onReceiverChange
|
||||
);
|
||||
|
||||
function onSelectorSelected(ev: CustomEvent<ReceiverSelectionCast>) {
|
||||
logger.info("Selected receiver", ev.detail);
|
||||
|
||||
resolve({
|
||||
actionType: ReceiverSelectionActionType.Cast,
|
||||
receiverDevice: ev.detail.receiverDevice,
|
||||
mediaType: ev.detail.mediaType
|
||||
});
|
||||
}
|
||||
function onSelectorStop(ev: CustomEvent<ReceiverSelectionStop>) {
|
||||
logger.info("Stopping receiver app...", ev.detail);
|
||||
|
||||
deviceManager.stopReceiverApp(ev.detail.receiverDevice.id);
|
||||
|
||||
resolve({
|
||||
actionType: ReceiverSelectionActionType.Stop,
|
||||
receiverDevice: ev.detail.receiverDevice
|
||||
});
|
||||
}
|
||||
function onSelectorCancelled() {
|
||||
logger.info("Cancelled receiver selection");
|
||||
|
||||
resolve(null);
|
||||
}
|
||||
function onSelectorError(ev: CustomEvent<string>) {
|
||||
reject(ev.detail);
|
||||
}
|
||||
function onReceiverMessage(
|
||||
ev: CustomEvent<ReceiverSelectorReceiverMessage>
|
||||
) {
|
||||
deviceManager.sendReceiverMessage(
|
||||
ev.detail.deviceId,
|
||||
ev.detail.message
|
||||
);
|
||||
}
|
||||
function onMediaMessage(ev: CustomEvent<ReceiverSelectorMediaMessage>) {
|
||||
deviceManager.sendMediaMessage(
|
||||
ev.detail.deviceId,
|
||||
ev.detail.message
|
||||
);
|
||||
}
|
||||
|
||||
sharedSelector.addEventListener("selected", onSelectorSelected);
|
||||
sharedSelector.addEventListener("stop", onSelectorStop);
|
||||
sharedSelector.addEventListener("cancelled", onSelectorCancelled);
|
||||
sharedSelector.addEventListener("error", onSelectorError);
|
||||
sharedSelector.addEventListener("receiverMessage", onReceiverMessage);
|
||||
sharedSelector.addEventListener("mediaMessage", onMediaMessage);
|
||||
sharedSelector.addEventListener("close", removeListeners);
|
||||
|
||||
function removeListeners() {
|
||||
sharedSelector.removeEventListener("selected", onSelectorSelected);
|
||||
sharedSelector.removeEventListener("stop", onSelectorStop);
|
||||
sharedSelector.removeEventListener(
|
||||
"cancelled",
|
||||
onSelectorCancelled
|
||||
);
|
||||
sharedSelector.removeEventListener("error", onSelectorError);
|
||||
sharedSelector.removeEventListener(
|
||||
"receiverMessage",
|
||||
onReceiverMessage
|
||||
);
|
||||
sharedSelector.removeEventListener("mediaMessage", onMediaMessage);
|
||||
sharedSelector.removeEventListener("close", removeListeners);
|
||||
|
||||
deviceManager.removeEventListener(
|
||||
"receiverDeviceUp",
|
||||
onReceiverChange
|
||||
);
|
||||
deviceManager.removeEventListener(
|
||||
"receiverDeviceDown",
|
||||
onReceiverChange
|
||||
);
|
||||
deviceManager.removeEventListener(
|
||||
"receiverDeviceUpdated",
|
||||
onReceiverChange
|
||||
);
|
||||
deviceManager.removeEventListener(
|
||||
"receiverDeviceMediaUpdated",
|
||||
onReceiverChange
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure status manager is initialized
|
||||
await deviceManager.init();
|
||||
|
||||
let isRequestAppAudioCompatible: Optional<boolean>;
|
||||
if (castInstance?.appId) {
|
||||
const appTag = getAppTag(baseConfig, castInstance.appId);
|
||||
isRequestAppAudioCompatible = appTag?.supports_audio_only;
|
||||
}
|
||||
|
||||
sharedSelector.open({
|
||||
receiverDevices: deviceManager.getDevices(),
|
||||
defaultMediaType,
|
||||
availableMediaTypes,
|
||||
appId: castInstance?.appId,
|
||||
// Create page info
|
||||
pageInfo: pageUrl
|
||||
? {
|
||||
url: pageUrl,
|
||||
tabId: contextTabId,
|
||||
frameId: contextFrameId,
|
||||
sessionRequest: selectionOpts?.sessionRequest,
|
||||
isRequestAppAudioCompatible
|
||||
}
|
||||
: undefined
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
getSelection,
|
||||
getSelector
|
||||
};
|
||||
Reference in New Issue
Block a user