Add media controls (#229)
89
app/src/bridge/components/cast/discovery.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
import mdns from "mdns";
|
||||||
|
|
||||||
|
import { ReceiverDevice } from "../../messagingTypes";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chromecast TXT record
|
||||||
|
*/
|
||||||
|
interface CastRecord {
|
||||||
|
// Device ID
|
||||||
|
id: string;
|
||||||
|
// Model name (e.g. Chromecast, Google Nest Mini, etc...)
|
||||||
|
md: string;
|
||||||
|
// Friendly name (user-visible)
|
||||||
|
fn: string;
|
||||||
|
// Capabilities
|
||||||
|
ca: string;
|
||||||
|
// Version (?)
|
||||||
|
ve: string;
|
||||||
|
// Icon path (?)
|
||||||
|
ic: string;
|
||||||
|
|
||||||
|
cd: string;
|
||||||
|
rm: string;
|
||||||
|
st: string;
|
||||||
|
bs: string;
|
||||||
|
nf: string;
|
||||||
|
rs: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiscoveryOptions {
|
||||||
|
onDeviceFound(device: ReceiverDevice): void;
|
||||||
|
onDeviceDown(deviceId: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class Discovery {
|
||||||
|
browser = mdns.createBrowser(mdns.tcp("googlecast"), {
|
||||||
|
resolverSequence: [
|
||||||
|
mdns.rst.DNSServiceResolve(),
|
||||||
|
"DNSServiceGetAddrInfo" in mdns.dns_sd
|
||||||
|
? mdns.rst.DNSServiceGetAddrInfo()
|
||||||
|
: // Some issues on Linux with IPv6, so restrict to IPv4
|
||||||
|
mdns.rst.getaddrinfo({ families: [4] }),
|
||||||
|
mdns.rst.makeAddressesUnique()
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor(opts: DiscoveryOptions) {
|
||||||
|
/**
|
||||||
|
* When a service is found, gather device info from service object and
|
||||||
|
* TXT record, then send a `main:receiverDeviceUp` message.
|
||||||
|
*/
|
||||||
|
this.browser.on("serviceUp", service => {
|
||||||
|
// Filter invalid results
|
||||||
|
if (!service.txtRecord || !service.name) return;
|
||||||
|
|
||||||
|
const record = service.txtRecord as CastRecord;
|
||||||
|
const device: ReceiverDevice = {
|
||||||
|
id: record.id,
|
||||||
|
friendlyName: record.fn,
|
||||||
|
modelName: record.md,
|
||||||
|
capabilities: parseInt(record.ca),
|
||||||
|
host: service.addresses[0],
|
||||||
|
port: service.port
|
||||||
|
};
|
||||||
|
|
||||||
|
opts.onDeviceFound(device);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When a service is lost, send a `main:receiverDeviceDown` message with
|
||||||
|
* the service name as the `deviceId`.
|
||||||
|
*/
|
||||||
|
this.browser.on("serviceDown", service => {
|
||||||
|
// Filter invalid results
|
||||||
|
if (!service.name) return;
|
||||||
|
|
||||||
|
opts.onDeviceDown(service.name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
this.browser.start();
|
||||||
|
}
|
||||||
|
stop() {
|
||||||
|
this.browser.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,10 @@ export default class Remote extends CastClient {
|
|||||||
this.transportClient?.disconnect();
|
this.transportClient?.disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sendMediaMessage(message: SenderMediaMessage) {
|
||||||
|
this.transportClient?.sendMediaMessage(message);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle `NS_RECEIVER` messages from the receiver device.
|
* Handle `NS_RECEIVER` messages from the receiver device.
|
||||||
* On initial connection, a `GET_STATUS` message is sent that
|
* On initial connection, a `GET_STATUS` message is sent that
|
||||||
@@ -76,7 +80,8 @@ export default class Remote extends CastClient {
|
|||||||
|
|
||||||
this.transportClient.connect(this.host).then(() => {
|
this.transportClient.connect(this.host).then(() => {
|
||||||
this.transportClient?.sendMediaMessage({
|
this.transportClient?.sendMediaMessage({
|
||||||
type: "GET_STATUS"
|
type: "GET_STATUS",
|
||||||
|
requestId: 0
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -284,7 +284,7 @@ export interface MediaStatus {
|
|||||||
playerState: PlayerState;
|
playerState: PlayerState;
|
||||||
idleReason?: IdleReason;
|
idleReason?: IdleReason;
|
||||||
items?: QueueItem[];
|
items?: QueueItem[];
|
||||||
currentTime: number;
|
currentTime: Nullable<number>;
|
||||||
supportedMediaCommands: number;
|
supportedMediaCommands: number;
|
||||||
repeatMode: RepeatMode;
|
repeatMode: RepeatMode;
|
||||||
volume: Volume;
|
volume: Volume;
|
||||||
@@ -357,11 +357,13 @@ export type SenderMediaMessage =
|
|||||||
type: "MEDIA_GET_STATUS";
|
type: "MEDIA_GET_STATUS";
|
||||||
mediaSessionId?: number;
|
mediaSessionId?: number;
|
||||||
customData?: unknown;
|
customData?: unknown;
|
||||||
|
requestId: number;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: "GET_STATUS";
|
type: "GET_STATUS";
|
||||||
mediaSessionId?: number;
|
mediaSessionId?: number;
|
||||||
customData?: unknown;
|
customData?: unknown;
|
||||||
|
requestId: number;
|
||||||
}
|
}
|
||||||
| (MediaReqBase & { type: "STOP" })
|
| (MediaReqBase & { type: "STOP" })
|
||||||
| (MediaReqBase & { type: "MEDIA_SET_VOLUME"; volume: Volume })
|
| (MediaReqBase & { type: "MEDIA_SET_VOLUME"; volume: Volume })
|
||||||
@@ -369,24 +371,24 @@ export type SenderMediaMessage =
|
|||||||
| (MediaReqBase & { type: "SET_PLAYBACK_RATE"; playbackRate: number })
|
| (MediaReqBase & { type: "SET_PLAYBACK_RATE"; playbackRate: number })
|
||||||
| (ReqBase & {
|
| (ReqBase & {
|
||||||
type: "LOAD";
|
type: "LOAD";
|
||||||
activeTrackIds: Nullable<number[]>;
|
activeTrackIds?: Nullable<number[]>;
|
||||||
atvCredentials?: string;
|
atvCredentials?: string;
|
||||||
atvCredentialsType?: string;
|
atvCredentialsType?: string;
|
||||||
autoplay: Nullable<boolean>;
|
autoplay?: Nullable<boolean>;
|
||||||
currentTime: Nullable<number>;
|
currentTime?: Nullable<number>;
|
||||||
customData?: unknown;
|
customData?: unknown;
|
||||||
media: MediaInformation;
|
media: MediaInformation;
|
||||||
sessionId: Nullable<string>;
|
sessionId?: Nullable<string>;
|
||||||
})
|
})
|
||||||
| (MediaReqBase & {
|
| (MediaReqBase & {
|
||||||
type: "SEEK";
|
type: "SEEK";
|
||||||
resumeState: Nullable<ResumeState>;
|
resumeState?: Nullable<ResumeState>;
|
||||||
currentTime: Nullable<number>;
|
currentTime?: Nullable<number>;
|
||||||
})
|
})
|
||||||
| (MediaReqBase & {
|
| (MediaReqBase & {
|
||||||
type: "EDIT_TRACKS_INFO";
|
type: "EDIT_TRACKS_INFO";
|
||||||
activeTrackIds: Nullable<number[]>;
|
activeTrackIds?: Nullable<number[]>;
|
||||||
textTrackStyle: Nullable<string>;
|
textTrackStyle?: Nullable<string>;
|
||||||
})
|
})
|
||||||
// QueueLoadRequest
|
// QueueLoadRequest
|
||||||
| (MediaReqBase & {
|
| (MediaReqBase & {
|
||||||
@@ -394,46 +396,46 @@ export type SenderMediaMessage =
|
|||||||
items: QueueItem[];
|
items: QueueItem[];
|
||||||
startIndex: number;
|
startIndex: number;
|
||||||
repeatMode: string;
|
repeatMode: string;
|
||||||
sessionId: Nullable<string>;
|
sessionId?: Nullable<string>;
|
||||||
})
|
})
|
||||||
// QueueInsertItemsRequest
|
// QueueInsertItemsRequest
|
||||||
| (MediaReqBase & {
|
| (MediaReqBase & {
|
||||||
type: "QUEUE_INSERT";
|
type: "QUEUE_INSERT";
|
||||||
items: QueueItem[];
|
items: QueueItem[];
|
||||||
insertBefore: Nullable<number>;
|
insertBefore?: Nullable<number>;
|
||||||
sessionId: Nullable<string>;
|
sessionId?: Nullable<string>;
|
||||||
})
|
})
|
||||||
// QueueUpdateItemsRequest
|
// QueueUpdateItemsRequest
|
||||||
| (MediaReqBase & {
|
| (MediaReqBase & {
|
||||||
type: "QUEUE_UPDATE";
|
type: "QUEUE_UPDATE";
|
||||||
items: QueueItem[];
|
items: QueueItem[];
|
||||||
sessionId: Nullable<string>;
|
sessionId?: Nullable<string>;
|
||||||
})
|
})
|
||||||
// QueueJumpRequest
|
// QueueJumpRequest
|
||||||
| (MediaReqBase & {
|
| (MediaReqBase & {
|
||||||
type: "QUEUE_UPDATE";
|
type: "QUEUE_UPDATE";
|
||||||
jump: Nullable<number>;
|
jump?: Nullable<number>;
|
||||||
currentItemId: Nullable<number>;
|
currentItemId?: Nullable<number>;
|
||||||
sessionId: Nullable<string>;
|
sessionId?: Nullable<string>;
|
||||||
})
|
})
|
||||||
// QueueRemoveItemsRequest
|
// QueueRemoveItemsRequest
|
||||||
| (MediaReqBase & {
|
| (MediaReqBase & {
|
||||||
type: "QUEUE_REMOVE";
|
type: "QUEUE_REMOVE";
|
||||||
itemIds: number[];
|
itemIds: number[];
|
||||||
sessionId: Nullable<string>;
|
sessionId?: Nullable<string>;
|
||||||
})
|
})
|
||||||
// QueueReorderItemsRequest
|
// QueueReorderItemsRequest
|
||||||
| (MediaReqBase & {
|
| (MediaReqBase & {
|
||||||
type: "QUEUE_REORDER";
|
type: "QUEUE_REORDER";
|
||||||
itemIds: number[];
|
itemIds: number[];
|
||||||
insertBefore: Nullable<number>;
|
insertBefore?: Nullable<number>;
|
||||||
sessionId: Nullable<string>;
|
sessionId?: Nullable<string>;
|
||||||
})
|
})
|
||||||
// QueueSetPropertiesRequest
|
// QueueSetPropertiesRequest
|
||||||
| (MediaReqBase & {
|
| (MediaReqBase & {
|
||||||
type: "QUEUE_UPDATE";
|
type: "QUEUE_UPDATE";
|
||||||
repeatMode: Nullable<string>;
|
repeatMode?: Nullable<string>;
|
||||||
sessionId: Nullable<string>;
|
sessionId?: Nullable<string>;
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ReceiverMediaMessage =
|
export type ReceiverMediaMessage =
|
||||||
|
|||||||
@@ -1,133 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
|
|
||||||
import mdns from "mdns";
|
|
||||||
|
|
||||||
import messaging from "../messaging";
|
|
||||||
import { ReceiverDevice } from "../messagingTypes";
|
|
||||||
|
|
||||||
import Remote from "./cast/remote";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Chromecast TXT record
|
|
||||||
*/
|
|
||||||
interface CastRecord {
|
|
||||||
// Device ID
|
|
||||||
id: string;
|
|
||||||
// Model name (e.g. Chromecast, Google Nest Mini, etc...)
|
|
||||||
md: string;
|
|
||||||
// Friendly name (user-visible)
|
|
||||||
fn: string;
|
|
||||||
// Capabilities
|
|
||||||
ca: string;
|
|
||||||
// Version (?)
|
|
||||||
ve: string;
|
|
||||||
// Icon path (?)
|
|
||||||
ic: string;
|
|
||||||
|
|
||||||
cd: string;
|
|
||||||
rm: string;
|
|
||||||
st: string;
|
|
||||||
bs: string;
|
|
||||||
nf: string;
|
|
||||||
rs: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const browser = mdns.createBrowser(mdns.tcp("googlecast"), {
|
|
||||||
resolverSequence: [
|
|
||||||
mdns.rst.DNSServiceResolve(),
|
|
||||||
"DNSServiceGetAddrInfo" in mdns.dns_sd
|
|
||||||
? mdns.rst.DNSServiceGetAddrInfo()
|
|
||||||
: // Some issues on Linux with IPv6, so restrict to IPv4
|
|
||||||
mdns.rst.getaddrinfo({ families: [4] }),
|
|
||||||
mdns.rst.makeAddressesUnique()
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
interface InitializeOptions {
|
|
||||||
shouldWatchStatus?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
let shouldWatchStatus: boolean;
|
|
||||||
export function startDiscovery(options: InitializeOptions) {
|
|
||||||
shouldWatchStatus = options.shouldWatchStatus ?? false;
|
|
||||||
|
|
||||||
browser.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function stopDiscovery() {
|
|
||||||
browser.stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map of device IDs to remote instances.
|
|
||||||
*/
|
|
||||||
const remotes = new Map<string, Remote>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* When a service is found, gather device info from service object and
|
|
||||||
* TXT record, then send a `main:receiverDeviceUp` message.
|
|
||||||
*/
|
|
||||||
browser.on("serviceUp", service => {
|
|
||||||
// Filter invalid results
|
|
||||||
if (!service.txtRecord || !service.name) return;
|
|
||||||
|
|
||||||
const record = service.txtRecord as CastRecord;
|
|
||||||
const device: ReceiverDevice = {
|
|
||||||
id: record.id,
|
|
||||||
friendlyName: record.fn,
|
|
||||||
modelName: record.md,
|
|
||||||
capabilities: parseInt(record.ca),
|
|
||||||
host: service.addresses[0],
|
|
||||||
port: service.port
|
|
||||||
};
|
|
||||||
|
|
||||||
messaging.sendMessage({
|
|
||||||
subject: "main:receiverDeviceUp",
|
|
||||||
data: {
|
|
||||||
deviceId: device.id,
|
|
||||||
deviceInfo: device
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (shouldWatchStatus) {
|
|
||||||
remotes.set(
|
|
||||||
service.name,
|
|
||||||
new Remote(device.host, {
|
|
||||||
// RECEIVER_STATUS
|
|
||||||
onReceiverStatusUpdate(status) {
|
|
||||||
messaging.sendMessage({
|
|
||||||
subject: "main:receiverDeviceStatusUpdated",
|
|
||||||
data: { deviceId: device.id, status }
|
|
||||||
});
|
|
||||||
},
|
|
||||||
// MEDIA_STATUS
|
|
||||||
onMediaStatusUpdate(status) {
|
|
||||||
if (!status) return;
|
|
||||||
|
|
||||||
messaging.sendMessage({
|
|
||||||
subject: "main:receiverDeviceMediaStatusUpdated",
|
|
||||||
data: { deviceId: device.id, status }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* When a service is lost, send a `main:receiverDeviceDown` message with
|
|
||||||
* the service name as the `deviceId`.
|
|
||||||
*/
|
|
||||||
browser.on("serviceDown", service => {
|
|
||||||
// Filter invalid results
|
|
||||||
if (!service.name) return;
|
|
||||||
|
|
||||||
messaging.sendMessage({
|
|
||||||
subject: "main:receiverDeviceDown",
|
|
||||||
data: { deviceId: service.name }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (shouldWatchStatus) {
|
|
||||||
remotes.get(service.name)?.disconnect();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -3,16 +3,21 @@
|
|||||||
import messaging, { Message } from "./messaging";
|
import messaging, { Message } from "./messaging";
|
||||||
|
|
||||||
import { handleCastMessage } from "./components/cast";
|
import { handleCastMessage } from "./components/cast";
|
||||||
import { startDiscovery, stopDiscovery } from "./components/discovery";
|
import Discovery from "./components/cast/discovery";
|
||||||
|
import Remote from "./components/cast/remote";
|
||||||
|
|
||||||
import { startMediaServer, stopMediaServer } from "./components/mediaServer";
|
import { startMediaServer, stopMediaServer } from "./components/mediaServer";
|
||||||
|
|
||||||
import { applicationVersion } from "../../config.json";
|
import { applicationVersion } from "../../config.json";
|
||||||
|
|
||||||
process.on("SIGTERM", () => {
|
process.on("SIGTERM", () => {
|
||||||
stopDiscovery();
|
discovery?.stop();
|
||||||
stopMediaServer();
|
stopMediaServer();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let discovery: Discovery | null = null;
|
||||||
|
const remotes = new Map<string, Remote>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle incoming messages from the extension and forward
|
* Handle incoming messages from the extension and forward
|
||||||
* them to the appropriate handlers.
|
* them to the appropriate handlers.
|
||||||
@@ -29,7 +34,75 @@ messaging.on("message", (message: Message) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "bridge:startDiscovery": {
|
case "bridge:startDiscovery": {
|
||||||
startDiscovery(message.data);
|
const { shouldWatchStatus } = message.data;
|
||||||
|
|
||||||
|
discovery = new Discovery({
|
||||||
|
onDeviceFound(device) {
|
||||||
|
messaging.sendMessage({
|
||||||
|
subject: "main:receiverDeviceUp",
|
||||||
|
data: {
|
||||||
|
deviceId: device.id,
|
||||||
|
deviceInfo: device
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (shouldWatchStatus) {
|
||||||
|
remotes.set(
|
||||||
|
device.id,
|
||||||
|
new Remote(device.host, {
|
||||||
|
// RECEIVER_STATUS
|
||||||
|
onReceiverStatusUpdate(status) {
|
||||||
|
messaging.sendMessage({
|
||||||
|
subject:
|
||||||
|
"main:receiverDeviceStatusUpdated",
|
||||||
|
data: {
|
||||||
|
deviceId: device.id,
|
||||||
|
status
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// MEDIA_STATUS
|
||||||
|
onMediaStatusUpdate(status) {
|
||||||
|
if (!status) return;
|
||||||
|
|
||||||
|
messaging.sendMessage({
|
||||||
|
subject:
|
||||||
|
"main:receiverDeviceMediaStatusUpdated",
|
||||||
|
data: {
|
||||||
|
deviceId: device.id,
|
||||||
|
status
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDeviceDown(deviceId) {
|
||||||
|
messaging.sendMessage({
|
||||||
|
subject: "main:receiverDeviceDown",
|
||||||
|
data: { deviceId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (shouldWatchStatus) {
|
||||||
|
remotes.get(deviceId)?.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
discovery.start();
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "bridge:sendReceiverMessage": {
|
||||||
|
const { deviceId, message: receiverMessage } = message.data;
|
||||||
|
remotes.get(deviceId)?.sendReceiverMessage(receiverMessage);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "bridge:sendMediaMessage": {
|
||||||
|
const { deviceId, message: mediaMessage } = message.data;
|
||||||
|
remotes.get(deviceId)?.sendMediaMessage(mediaMessage);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { DecodeTransform, EncodeTransform } from "../transforms";
|
|||||||
import {
|
import {
|
||||||
MediaStatus,
|
MediaStatus,
|
||||||
ReceiverStatus,
|
ReceiverStatus,
|
||||||
|
SenderMediaMessage,
|
||||||
SenderMessage
|
SenderMessage
|
||||||
} from "./components/cast/types";
|
} from "./components/cast/types";
|
||||||
|
|
||||||
@@ -71,6 +72,23 @@ type MessageDefinitions = {
|
|||||||
status: MediaStatus;
|
status: MediaStatus;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sent to the bridge when non-session related receiver messages
|
||||||
|
* need to be sent (e.g. volume control, application stop, etc...).
|
||||||
|
*/
|
||||||
|
"bridge:sendReceiverMessage": {
|
||||||
|
deviceId: string;
|
||||||
|
message: SenderMessage;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Sent to the bridge when the receiver selector media UI is used
|
||||||
|
* to control media playback.
|
||||||
|
*/
|
||||||
|
"bridge:sendMediaMessage": {
|
||||||
|
deviceId: string;
|
||||||
|
message: SenderMediaMessage;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sent to bridge from cast API instance when a session request is
|
* Sent to bridge from cast API instance when a session request is
|
||||||
* initiated.
|
* initiated.
|
||||||
|
|||||||
@@ -101,6 +101,62 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"popupShowDetailsTitle": {
|
||||||
|
"message": "Show details",
|
||||||
|
"description": "Receiver device expand button title."
|
||||||
|
},
|
||||||
|
"popupMediaPlay": {
|
||||||
|
"message": "Play",
|
||||||
|
"description": "Media controls play button title."
|
||||||
|
},
|
||||||
|
"popupMediaPause": {
|
||||||
|
"message": "Pause",
|
||||||
|
"description": "Media controls pause button title."
|
||||||
|
},
|
||||||
|
"popupMediaSkipPrevious": {
|
||||||
|
"message": "Skip previous",
|
||||||
|
"description": "Media controls skip previous button title."
|
||||||
|
},
|
||||||
|
"popupMediaSkipNext": {
|
||||||
|
"message": "Skip next",
|
||||||
|
"description": "Media controls skip next button title."
|
||||||
|
},
|
||||||
|
"popupMediaSeek": {
|
||||||
|
"message": "Seek position",
|
||||||
|
"description": "Media controls seek bar accessibility label."
|
||||||
|
},
|
||||||
|
"popupMediaSeekBackward": {
|
||||||
|
"message": "Seek backwards",
|
||||||
|
"description": "Media controls seek backward button title."
|
||||||
|
},
|
||||||
|
"popupMediaSeekForward": {
|
||||||
|
"message": "Seek forwards",
|
||||||
|
"description": "Media controls seek forward button title."
|
||||||
|
},
|
||||||
|
"popupMediaSubtitlesClosedCaptions": {
|
||||||
|
"message": "Subtitles/closed captions",
|
||||||
|
"description": "Media controls subtitles/cc button title."
|
||||||
|
},
|
||||||
|
"popupMediaSubtitlesClosedCaptionsOff": {
|
||||||
|
"message": "Off",
|
||||||
|
"description": "Media controls subtitles/cc button title."
|
||||||
|
},
|
||||||
|
"popupMediaMute": {
|
||||||
|
"message": "Mute",
|
||||||
|
"description": "Media controls mute button title."
|
||||||
|
},
|
||||||
|
"popupMediaUnmute": {
|
||||||
|
"message": "Unmute",
|
||||||
|
"description": "Media controls unmute button title."
|
||||||
|
},
|
||||||
|
"popupMediaVolume": {
|
||||||
|
"message": "Unmute",
|
||||||
|
"description": "Media controls volume slider accessibility label."
|
||||||
|
},
|
||||||
|
"popupMediaLive": {
|
||||||
|
"message": "Live",
|
||||||
|
"description": "Media controls label displayed for live streams."
|
||||||
|
},
|
||||||
|
|
||||||
"contextCast": {
|
"contextCast": {
|
||||||
"message": "Cast...",
|
"message": "Cast...",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import messaging, { Port, Message } from "../messaging";
|
|||||||
import options from "../lib/options";
|
import options from "../lib/options";
|
||||||
|
|
||||||
import { TypedEventTarget } from "../lib/TypedEventTarget";
|
import { TypedEventTarget } from "../lib/TypedEventTarget";
|
||||||
|
import { SenderMediaMessage, SenderMessage } from "../cast/sdk/types";
|
||||||
import {
|
import {
|
||||||
ReceiverDevice,
|
ReceiverDevice,
|
||||||
ReceiverSelectionActionType,
|
ReceiverSelectionActionType,
|
||||||
@@ -25,13 +26,25 @@ export interface ReceiverSelectionStop {
|
|||||||
}
|
}
|
||||||
export type ReceiverSelection = ReceiverSelectionCast | ReceiverSelectionStop;
|
export type ReceiverSelection = ReceiverSelectionCast | ReceiverSelectionStop;
|
||||||
|
|
||||||
|
export interface ReceiverSelectorReceiverMessage {
|
||||||
|
deviceId: string;
|
||||||
|
message: SenderMessage;
|
||||||
|
}
|
||||||
|
export interface ReceiverSelectorMediaMessage {
|
||||||
|
deviceId: string;
|
||||||
|
message: SenderMediaMessage;
|
||||||
|
}
|
||||||
|
|
||||||
interface ReceiverSelectorEvents {
|
interface ReceiverSelectorEvents {
|
||||||
selected: ReceiverSelectionCast;
|
selected: ReceiverSelectionCast;
|
||||||
error: string;
|
error: string;
|
||||||
cancelled: void;
|
cancelled: void;
|
||||||
stop: ReceiverSelectionStop;
|
stop: ReceiverSelectionStop;
|
||||||
close: void;
|
close: void;
|
||||||
|
receiverMessage: ReceiverSelectorReceiverMessage;
|
||||||
|
mediaMessage: ReceiverSelectorMediaMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages the receiver selector popup window and communication with the
|
* Manages the receiver selector popup window and communication with the
|
||||||
* extension page hosted within.
|
* extension page hosted within.
|
||||||
@@ -100,7 +113,7 @@ export default class ReceiverSelector extends TypedEventTarget<ReceiverSelectorE
|
|||||||
this.availableMediaTypes = opts.availableMediaTypes;
|
this.availableMediaTypes = opts.availableMediaTypes;
|
||||||
|
|
||||||
const popupSizePosition = {
|
const popupSizePosition = {
|
||||||
width: 350,
|
width: 400,
|
||||||
height: 200,
|
height: 200,
|
||||||
left: 100,
|
left: 100,
|
||||||
top: 100
|
top: 100
|
||||||
@@ -238,6 +251,21 @@ export default class ReceiverSelector extends TypedEventTarget<ReceiverSelectorE
|
|||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "receiverSelector:receiverMessage":
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent("receiverMessage", {
|
||||||
|
detail: message.data
|
||||||
|
})
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "receiverSelector:mediaMessage":
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent("mediaMessage", {
|
||||||
|
detail: message.data
|
||||||
|
})
|
||||||
|
);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,13 @@ import { TypedEventTarget } from "../lib/TypedEventTarget";
|
|||||||
|
|
||||||
import { Message, Port } from "../messaging";
|
import { Message, Port } from "../messaging";
|
||||||
import { ReceiverDevice } from "../types";
|
import { ReceiverDevice } from "../types";
|
||||||
import { ReceiverStatus } from "../cast/sdk/types";
|
import {
|
||||||
|
MediaStatus,
|
||||||
|
ReceiverStatus,
|
||||||
|
SenderMediaMessage,
|
||||||
|
SenderMessage
|
||||||
|
} from "../cast/sdk/types";
|
||||||
|
import { PlayerState } from "../cast/sdk/media/enums";
|
||||||
|
|
||||||
interface EventMap {
|
interface EventMap {
|
||||||
receiverDeviceUp: { deviceInfo: ReceiverDevice };
|
receiverDeviceUp: { deviceInfo: ReceiverDevice };
|
||||||
@@ -15,6 +21,10 @@ interface EventMap {
|
|||||||
deviceId: string;
|
deviceId: string;
|
||||||
status: ReceiverStatus;
|
status: ReceiverStatus;
|
||||||
};
|
};
|
||||||
|
receiverDeviceMediaUpdated: {
|
||||||
|
deviceId: string;
|
||||||
|
status: MediaStatus;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new (class extends TypedEventTarget<EventMap> {
|
export default new (class extends TypedEventTarget<EventMap> {
|
||||||
@@ -79,6 +89,48 @@ export default new (class extends TypedEventTarget<EventMap> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sendReceiverMessage(deviceId: string, message: SenderMessage) {
|
||||||
|
if (!this.bridgePort) {
|
||||||
|
logger.error(
|
||||||
|
"Failed to send receiver message (no bridge connection)"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const device = this.receiverDevices.get(deviceId);
|
||||||
|
if (!device) {
|
||||||
|
logger.error(
|
||||||
|
"Failed to send receiver message (could not find device)"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bridgePort?.postMessage({
|
||||||
|
subject: "bridge:sendReceiverMessage",
|
||||||
|
data: { deviceId, message }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
sendMediaMessage(deviceId: string, message: SenderMediaMessage) {
|
||||||
|
if (!this.bridgePort) {
|
||||||
|
logger.error("Failed to send media message (no bridge connection)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const device = this.receiverDevices.get(deviceId);
|
||||||
|
if (!device) {
|
||||||
|
logger.error(
|
||||||
|
"Failed to send media message (could not find device)"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bridgePort?.postMessage({
|
||||||
|
subject: "bridge:sendMediaMessage",
|
||||||
|
data: { deviceId, message }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private onBridgeMessage = (message: Message) => {
|
private onBridgeMessage = (message: Message) => {
|
||||||
switch (message.subject) {
|
switch (message.subject) {
|
||||||
case "main:receiverDeviceUp": {
|
case "main:receiverDeviceUp": {
|
||||||
@@ -111,29 +163,22 @@ export default new (class extends TypedEventTarget<EventMap> {
|
|||||||
|
|
||||||
case "main:receiverDeviceStatusUpdated": {
|
case "main:receiverDeviceStatusUpdated": {
|
||||||
const { deviceId, status } = message.data;
|
const { deviceId, status } = message.data;
|
||||||
const receiverDevice = this.receiverDevices.get(deviceId);
|
const device = this.receiverDevices.get(deviceId);
|
||||||
if (!receiverDevice) {
|
if (!device) break;
|
||||||
break;
|
|
||||||
|
// Clear media status when app status changes
|
||||||
|
const application = status.applications?.[0];
|
||||||
|
if (!application || application.isIdleScreen) {
|
||||||
|
delete device.mediaStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (receiverDevice.status) {
|
device.status = status;
|
||||||
receiverDevice.status.isActiveInput = status.isActiveInput;
|
|
||||||
receiverDevice.status.isStandBy = status.isStandBy;
|
|
||||||
receiverDevice.status.volume = status.volume;
|
|
||||||
|
|
||||||
if (status.applications) {
|
|
||||||
receiverDevice.status.applications =
|
|
||||||
status.applications;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
receiverDevice.status = status;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.dispatchEvent(
|
this.dispatchEvent(
|
||||||
new CustomEvent("receiverDeviceUpdated", {
|
new CustomEvent("receiverDeviceUpdated", {
|
||||||
detail: {
|
detail: {
|
||||||
deviceId,
|
deviceId,
|
||||||
status: receiverDevice.status
|
status: device.status
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -142,6 +187,28 @@ export default new (class extends TypedEventTarget<EventMap> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "main:receiverDeviceMediaStatusUpdated": {
|
case "main:receiverDeviceMediaStatusUpdated": {
|
||||||
|
const { deviceId, status } = message.data;
|
||||||
|
const device = this.receiverDevices.get(deviceId);
|
||||||
|
if (!device) break;
|
||||||
|
|
||||||
|
if (device.mediaStatus) {
|
||||||
|
device.mediaStatus = { ...device.mediaStatus, ...status };
|
||||||
|
if (status.playerState === PlayerState.IDLE) {
|
||||||
|
delete device.mediaStatus.media;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
device.mediaStatus = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent("receiverDeviceMediaUpdated", {
|
||||||
|
detail: {
|
||||||
|
deviceId,
|
||||||
|
status: device.mediaStatus
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ import deviceManager from "./deviceManager";
|
|||||||
import ReceiverSelector, {
|
import ReceiverSelector, {
|
||||||
ReceiverSelection,
|
ReceiverSelection,
|
||||||
ReceiverSelectionCast,
|
ReceiverSelectionCast,
|
||||||
ReceiverSelectionStop
|
ReceiverSelectionStop,
|
||||||
|
ReceiverSelectorMediaMessage,
|
||||||
|
ReceiverSelectorReceiverMessage
|
||||||
} from "./ReceiverSelector";
|
} from "./ReceiverSelector";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -122,6 +124,10 @@ async function getSelection(
|
|||||||
"receiverDeviceUpdated",
|
"receiverDeviceUpdated",
|
||||||
onReceiverChange
|
onReceiverChange
|
||||||
);
|
);
|
||||||
|
deviceManager.addEventListener(
|
||||||
|
"receiverDeviceMediaUpdated",
|
||||||
|
onReceiverChange
|
||||||
|
);
|
||||||
|
|
||||||
function onSelectorSelected(ev: CustomEvent<ReceiverSelectionCast>) {
|
function onSelectorSelected(ev: CustomEvent<ReceiverSelectionCast>) {
|
||||||
logger.info("Selected receiver", ev.detail);
|
logger.info("Selected receiver", ev.detail);
|
||||||
@@ -150,11 +156,27 @@ async function getSelection(
|
|||||||
function onSelectorError(ev: CustomEvent<string>) {
|
function onSelectorError(ev: CustomEvent<string>) {
|
||||||
reject(ev.detail);
|
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("selected", onSelectorSelected);
|
||||||
sharedSelector.addEventListener("stop", onSelectorStop);
|
sharedSelector.addEventListener("stop", onSelectorStop);
|
||||||
sharedSelector.addEventListener("cancelled", onSelectorCancelled);
|
sharedSelector.addEventListener("cancelled", onSelectorCancelled);
|
||||||
sharedSelector.addEventListener("error", onSelectorError);
|
sharedSelector.addEventListener("error", onSelectorError);
|
||||||
|
sharedSelector.addEventListener("receiverMessage", onReceiverMessage);
|
||||||
|
sharedSelector.addEventListener("mediaMessage", onMediaMessage);
|
||||||
sharedSelector.addEventListener("close", removeListeners);
|
sharedSelector.addEventListener("close", removeListeners);
|
||||||
|
|
||||||
function removeListeners() {
|
function removeListeners() {
|
||||||
@@ -165,6 +187,11 @@ async function getSelection(
|
|||||||
onSelectorCancelled
|
onSelectorCancelled
|
||||||
);
|
);
|
||||||
sharedSelector.removeEventListener("error", onSelectorError);
|
sharedSelector.removeEventListener("error", onSelectorError);
|
||||||
|
sharedSelector.removeEventListener(
|
||||||
|
"receiverMessage",
|
||||||
|
onReceiverMessage
|
||||||
|
);
|
||||||
|
sharedSelector.removeEventListener("mediaMessage", onMediaMessage);
|
||||||
sharedSelector.removeEventListener("close", removeListeners);
|
sharedSelector.removeEventListener("close", removeListeners);
|
||||||
|
|
||||||
deviceManager.removeEventListener(
|
deviceManager.removeEventListener(
|
||||||
@@ -179,6 +206,10 @@ async function getSelection(
|
|||||||
"receiverDeviceUpdated",
|
"receiverDeviceUpdated",
|
||||||
onReceiverChange
|
onReceiverChange
|
||||||
);
|
);
|
||||||
|
deviceManager.removeEventListener(
|
||||||
|
"receiverDeviceMediaUpdated",
|
||||||
|
onReceiverChange
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure status manager is initialized
|
// Ensure status manager is initialized
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ import {
|
|||||||
MediaStatus,
|
MediaStatus,
|
||||||
ReceiverMediaMessage,
|
ReceiverMediaMessage,
|
||||||
SenderMediaMessage,
|
SenderMediaMessage,
|
||||||
SenderMessage
|
SenderMessage,
|
||||||
|
_MediaCommand
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
import { SessionStatus } from "./enums";
|
import { SessionStatus } from "./enums";
|
||||||
@@ -29,16 +30,6 @@ import { MediaCommand } from "./media/enums";
|
|||||||
import { LoadRequest, QueueLoadRequest, QueueItem } from "./media/classes";
|
import { LoadRequest, QueueLoadRequest, QueueItem } from "./media/classes";
|
||||||
import Media, { NS_MEDIA } from "./media/Media";
|
import Media, { NS_MEDIA } from "./media/Media";
|
||||||
|
|
||||||
/** supportedMediaCommands bitflag returned in MEDIA_STATUS messages */
|
|
||||||
enum _MediaCommand {
|
|
||||||
PAUSE = 1,
|
|
||||||
SEEK = 2,
|
|
||||||
STREAM_VOLUME = 4,
|
|
||||||
STREAM_MUTE = 8,
|
|
||||||
QUEUE_NEXT = 64,
|
|
||||||
QUEUE_PREV = 128
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Takes a media object and a media status object and merges the status
|
* Takes a media object and a media status object and merges the status
|
||||||
* with the existing media object, updating it with new properties.
|
* with the existing media object, updating it with new properties.
|
||||||
|
|||||||
@@ -15,16 +15,6 @@ import {
|
|||||||
UserAction
|
UserAction
|
||||||
} from "./enums";
|
} from "./enums";
|
||||||
|
|
||||||
export class AudiobookChapterMediaMetadata {
|
|
||||||
bookTitle?: string;
|
|
||||||
chapterNumber?: number;
|
|
||||||
chapterTitle?: string;
|
|
||||||
images?: Image[];
|
|
||||||
subtitle?: string;
|
|
||||||
title?: string;
|
|
||||||
type = MetadataType.AUDIOBOOK_CHAPTER;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AudiobookContainerMetadata {
|
export class AudiobookContainerMetadata {
|
||||||
authors?: string[];
|
authors?: string[];
|
||||||
narrators?: string[];
|
narrators?: string[];
|
||||||
@@ -71,7 +61,7 @@ export class BreakStatus {
|
|||||||
export class ContainerMetadata {
|
export class ContainerMetadata {
|
||||||
containerDuration?: number;
|
containerDuration?: number;
|
||||||
containerImages?: Image[];
|
containerImages?: Image[];
|
||||||
sections?: MediaMetadata[];
|
sections?: Metadata[];
|
||||||
title?: string;
|
title?: string;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -88,16 +78,6 @@ export class EditTracksInfoRequest {
|
|||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GenericMediaMetadata {
|
|
||||||
images?: Image[];
|
|
||||||
metadataType = MetadataType.GENERIC;
|
|
||||||
releaseDate?: string;
|
|
||||||
releaseYear?: number;
|
|
||||||
subtitle?: string;
|
|
||||||
title?: string;
|
|
||||||
type = MetadataType.GENERIC;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class GetStatusRequest {
|
export class GetStatusRequest {
|
||||||
customData: unknown = null;
|
customData: unknown = null;
|
||||||
}
|
}
|
||||||
@@ -129,6 +109,7 @@ export class LoadRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type Metadata =
|
export type Metadata =
|
||||||
|
| AudiobookChapterMediaMetadata
|
||||||
| GenericMediaMetadata
|
| GenericMediaMetadata
|
||||||
| MovieMediaMetadata
|
| MovieMediaMetadata
|
||||||
| MusicTrackMediaMetadata
|
| MusicTrackMediaMetadata
|
||||||
@@ -156,33 +137,60 @@ export class MediaInfo {
|
|||||||
constructor(public contentId: string, public contentType: string) {}
|
constructor(public contentId: string, public contentType: string) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MediaMetadata {
|
export abstract class MediaMetadata<T extends MetadataType> {
|
||||||
queueItemId?: number;
|
queueItemId?: number;
|
||||||
sectionDuration?: number;
|
sectionDuration?: number;
|
||||||
sectionStartAbsoluteTime?: number;
|
sectionStartAbsoluteTime?: number;
|
||||||
sectionStartTimeInContainer?: number;
|
sectionStartTimeInContainer?: number;
|
||||||
sectionStartTimeInMedia?: number;
|
sectionStartTimeInMedia?: number;
|
||||||
type: MetadataType;
|
type: T;
|
||||||
metadataType: MetadataType;
|
metadataType: T;
|
||||||
|
|
||||||
constructor(type: MetadataType) {
|
constructor(type: T) {
|
||||||
this.type = type;
|
this.type = type;
|
||||||
this.metadataType = type;
|
this.metadataType = type;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MovieMediaMetadata {
|
export class AudiobookChapterMediaMetadata extends MediaMetadata<MetadataType.AUDIOBOOK_CHAPTER> {
|
||||||
|
bookTitle?: string;
|
||||||
|
chapterNumber?: number;
|
||||||
|
chapterTitle?: string;
|
||||||
|
images?: Image[];
|
||||||
|
subtitle?: string;
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super(MetadataType.AUDIOBOOK_CHAPTER);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GenericMediaMetadata extends MediaMetadata<MetadataType.GENERIC> {
|
||||||
|
images?: Image[];
|
||||||
|
releaseDate?: string;
|
||||||
|
releaseYear?: number;
|
||||||
|
subtitle?: string;
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super(MetadataType.GENERIC);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MovieMediaMetadata extends MediaMetadata<MetadataType.MOVIE> {
|
||||||
images?: Image[];
|
images?: Image[];
|
||||||
metadataType = MetadataType.MOVIE;
|
|
||||||
releaseDate?: string;
|
releaseDate?: string;
|
||||||
releaseYear?: number;
|
releaseYear?: number;
|
||||||
studio?: string;
|
studio?: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
type = MetadataType.MOVIE;
|
|
||||||
|
constructor() {
|
||||||
|
super(MetadataType.MOVIE);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MusicTrackMediaMetadata {
|
export class MusicTrackMediaMetadata extends MediaMetadata<MetadataType.MUSIC_TRACK> {
|
||||||
albumArtist?: string;
|
albumArtist?: string;
|
||||||
albumName?: string;
|
albumName?: string;
|
||||||
artist?: string;
|
artist?: string;
|
||||||
@@ -190,20 +198,18 @@ export class MusicTrackMediaMetadata {
|
|||||||
composer?: string;
|
composer?: string;
|
||||||
discNumber?: number;
|
discNumber?: number;
|
||||||
images?: Image[];
|
images?: Image[];
|
||||||
metadataType = MetadataType.MUSIC_TRACK;
|
|
||||||
releaseDate?: string;
|
releaseDate?: string;
|
||||||
releaseYear?: number;
|
releaseYear?: number;
|
||||||
songName?: string;
|
songName?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
trackNumber?: number;
|
trackNumber?: number;
|
||||||
type = MetadataType.MUSIC_TRACK;
|
|
||||||
|
constructor() {
|
||||||
|
super(MetadataType.MUSIC_TRACK);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PauseRequest {
|
export class PhotoMediaMetadata extends MediaMetadata<MetadataType.PHOTO> {
|
||||||
customData: unknown = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class PhotoMediaMetadata {
|
|
||||||
artist?: string;
|
artist?: string;
|
||||||
creationDateTime?: string;
|
creationDateTime?: string;
|
||||||
height?: number;
|
height?: number;
|
||||||
@@ -211,10 +217,33 @@ export class PhotoMediaMetadata {
|
|||||||
latitude?: number;
|
latitude?: number;
|
||||||
location?: string;
|
location?: string;
|
||||||
longitude?: number;
|
longitude?: number;
|
||||||
metadataType = MetadataType.PHOTO;
|
|
||||||
title?: string;
|
title?: string;
|
||||||
type = MetadataType.PHOTO;
|
|
||||||
width?: number;
|
width?: number;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super(MetadataType.PHOTO);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TvShowMediaMetadata extends MediaMetadata<MetadataType.TV_SHOW> {
|
||||||
|
episode?: number;
|
||||||
|
episodeNumber?: number;
|
||||||
|
episodeTitle?: string;
|
||||||
|
images?: Image[];
|
||||||
|
originalAirdate?: string;
|
||||||
|
releaseYear?: number;
|
||||||
|
season?: number;
|
||||||
|
seasonNumber?: number;
|
||||||
|
seriesTitle?: string;
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super(MetadataType.TV_SHOW);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PauseRequest {
|
||||||
|
customData: unknown = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PlayRequest {
|
export class PlayRequest {
|
||||||
@@ -339,21 +368,6 @@ export class Track {
|
|||||||
constructor(public trackId: number, public type: TrackType) {}
|
constructor(public trackId: number, public type: TrackType) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TvShowMediaMetadata {
|
|
||||||
episode?: number;
|
|
||||||
episodeNumber?: number;
|
|
||||||
episodeTitle?: string;
|
|
||||||
images?: Image[];
|
|
||||||
metadataType: number = MetadataType.TV_SHOW;
|
|
||||||
originalAirdate?: string;
|
|
||||||
releaseYear?: number;
|
|
||||||
season?: number;
|
|
||||||
seasonNumber?: number;
|
|
||||||
seriesTitle?: string;
|
|
||||||
title?: string;
|
|
||||||
type = MetadataType.TV_SHOW;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UserActionState {
|
export class UserActionState {
|
||||||
customData: unknown = null;
|
customData: unknown = null;
|
||||||
|
|
||||||
|
|||||||
@@ -15,15 +15,17 @@ import {
|
|||||||
} from "./media/enums";
|
} from "./media/enums";
|
||||||
|
|
||||||
export interface MediaStatus {
|
export interface MediaStatus {
|
||||||
|
activeTrackIds?: number[];
|
||||||
|
currentItemId?: number;
|
||||||
mediaSessionId: number;
|
mediaSessionId: number;
|
||||||
media?: MediaInfo;
|
media?: MediaInfo;
|
||||||
playbackRate: number;
|
playbackRate: number;
|
||||||
playerState: PlayerState;
|
playerState: PlayerState;
|
||||||
idleReason?: IdleReason;
|
idleReason?: IdleReason;
|
||||||
items?: QueueItem[];
|
items?: QueueItem[];
|
||||||
currentTime: number;
|
currentTime: Nullable<number>;
|
||||||
supportedMediaCommands: number;
|
supportedMediaCommands: number;
|
||||||
repeatMode: RepeatMode;
|
repeatMode?: RepeatMode;
|
||||||
volume: Volume;
|
volume: Volume;
|
||||||
customData: unknown;
|
customData: unknown;
|
||||||
}
|
}
|
||||||
@@ -66,6 +68,23 @@ export interface CastSessionCreatedDetails extends CastSessionUpdatedDetails {
|
|||||||
transportId: string;
|
transportId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** supportedMediaCommands bitflag returned in MEDIA_STATUS messages */
|
||||||
|
export enum _MediaCommand {
|
||||||
|
PAUSE = 1,
|
||||||
|
SEEK = 2,
|
||||||
|
STREAM_VOLUME = 4,
|
||||||
|
STREAM_MUTE = 8,
|
||||||
|
QUEUE_NEXT = 64,
|
||||||
|
QUEUE_PREV = 128,
|
||||||
|
QUEUE_SHUFFLE = 256,
|
||||||
|
QUEUE_SKIP_AD = 512,
|
||||||
|
QUEUE_REPEAT_ALL = 1024,
|
||||||
|
QUEUE_REPEAT_ONE = 2048,
|
||||||
|
QUEUE_REPEAT = 3072,
|
||||||
|
EDIT_TRACKS = 4096,
|
||||||
|
PLAYBACK_RATE = 8192
|
||||||
|
}
|
||||||
|
|
||||||
interface ReqBase {
|
interface ReqBase {
|
||||||
requestId: number;
|
requestId: number;
|
||||||
}
|
}
|
||||||
@@ -91,77 +110,89 @@ interface MediaReqBase extends ReqBase {
|
|||||||
export type SenderMediaMessage =
|
export type SenderMediaMessage =
|
||||||
| (MediaReqBase & { type: "PLAY" })
|
| (MediaReqBase & { type: "PLAY" })
|
||||||
| (MediaReqBase & { type: "PAUSE" })
|
| (MediaReqBase & { type: "PAUSE" })
|
||||||
| (MediaReqBase & { type: "MEDIA_GET_STATUS" })
|
| {
|
||||||
|
type: "MEDIA_GET_STATUS";
|
||||||
|
mediaSessionId?: number;
|
||||||
|
customData?: unknown;
|
||||||
|
requestId: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "GET_STATUS";
|
||||||
|
mediaSessionId?: number;
|
||||||
|
customData?: unknown;
|
||||||
|
requestId: number;
|
||||||
|
}
|
||||||
| (MediaReqBase & { type: "STOP" })
|
| (MediaReqBase & { type: "STOP" })
|
||||||
| (MediaReqBase & { type: "MEDIA_SET_VOLUME"; volume: Partial<Volume> })
|
| (MediaReqBase & { type: "MEDIA_SET_VOLUME"; volume: Volume })
|
||||||
|
| (MediaReqBase & { type: "SET_VOLUME"; volume: Volume })
|
||||||
| (MediaReqBase & { type: "SET_PLAYBACK_RATE"; playbackRate: number })
|
| (MediaReqBase & { type: "SET_PLAYBACK_RATE"; playbackRate: number })
|
||||||
| (ReqBase & {
|
| (ReqBase & {
|
||||||
type: "LOAD";
|
type: "LOAD";
|
||||||
activeTrackIds: Nullable<number[]>;
|
activeTrackIds?: Nullable<number[]>;
|
||||||
atvCredentials?: string;
|
atvCredentials?: string;
|
||||||
atvCredentialsType?: string;
|
atvCredentialsType?: string;
|
||||||
autoplay: Nullable<boolean>;
|
autoplay?: Nullable<boolean>;
|
||||||
currentTime: Nullable<number>;
|
currentTime?: Nullable<number>;
|
||||||
customData?: unknown;
|
customData?: unknown;
|
||||||
media: MediaInfo;
|
media: MediaInfo;
|
||||||
sessionId: Nullable<string>;
|
sessionId?: Nullable<string>;
|
||||||
})
|
})
|
||||||
| (MediaReqBase & {
|
| (MediaReqBase & {
|
||||||
type: "SEEK";
|
type: "SEEK";
|
||||||
resumeState: Nullable<ResumeState>;
|
resumeState?: Nullable<ResumeState>;
|
||||||
currentTime: Nullable<number>;
|
currentTime?: Nullable<number>;
|
||||||
})
|
})
|
||||||
| (MediaReqBase & {
|
| (MediaReqBase & {
|
||||||
type: "EDIT_TRACKS_INFO";
|
type: "EDIT_TRACKS_INFO";
|
||||||
activeTrackIds: Nullable<number[]>;
|
activeTrackIds?: Nullable<number[]>;
|
||||||
textTrackStyle: Nullable<string>;
|
textTrackStyle?: Nullable<string>;
|
||||||
})
|
})
|
||||||
// QueueLoadRequest
|
// QueueLoadRequest
|
||||||
| (ReqBase & {
|
| (MediaReqBase & {
|
||||||
type: "QUEUE_LOAD";
|
type: "QUEUE_LOAD";
|
||||||
items: QueueItem[];
|
items: QueueItem[];
|
||||||
startIndex: number;
|
startIndex: number;
|
||||||
repeatMode: string;
|
repeatMode: string;
|
||||||
sessionId: Nullable<string>;
|
sessionId?: Nullable<string>;
|
||||||
})
|
})
|
||||||
// QueueInsertItemsRequest
|
// QueueInsertItemsRequest
|
||||||
| (MediaReqBase & {
|
| (MediaReqBase & {
|
||||||
type: "QUEUE_INSERT";
|
type: "QUEUE_INSERT";
|
||||||
items: QueueItem[];
|
items: QueueItem[];
|
||||||
insertBefore: Nullable<number>;
|
insertBefore?: Nullable<number>;
|
||||||
sessionId: Nullable<string>;
|
sessionId?: Nullable<string>;
|
||||||
})
|
})
|
||||||
// QueueUpdateItemsRequest
|
// QueueUpdateItemsRequest
|
||||||
| (MediaReqBase & {
|
| (MediaReqBase & {
|
||||||
type: "QUEUE_UPDATE";
|
type: "QUEUE_UPDATE";
|
||||||
items: QueueItem[];
|
items: QueueItem[];
|
||||||
sessionId: Nullable<string>;
|
sessionId?: Nullable<string>;
|
||||||
})
|
})
|
||||||
// QueueJumpRequest
|
// QueueJumpRequest
|
||||||
| (MediaReqBase & {
|
| (MediaReqBase & {
|
||||||
type: "QUEUE_UPDATE";
|
type: "QUEUE_UPDATE";
|
||||||
jump: Nullable<number>;
|
jump?: Nullable<number>;
|
||||||
currentItemId: Nullable<number>;
|
currentItemId?: Nullable<number>;
|
||||||
sessionId: Nullable<string>;
|
sessionId?: Nullable<string>;
|
||||||
})
|
})
|
||||||
// QueueRemoveItemsRequest
|
// QueueRemoveItemsRequest
|
||||||
| (MediaReqBase & {
|
| (MediaReqBase & {
|
||||||
type: "QUEUE_REMOVE";
|
type: "QUEUE_REMOVE";
|
||||||
itemIds: number[];
|
itemIds: number[];
|
||||||
sessionId: Nullable<string>;
|
sessionId?: Nullable<string>;
|
||||||
})
|
})
|
||||||
// QueueReorderItemsRequest
|
// QueueReorderItemsRequest
|
||||||
| (MediaReqBase & {
|
| (MediaReqBase & {
|
||||||
type: "QUEUE_REORDER";
|
type: "QUEUE_REORDER";
|
||||||
itemIds: number[];
|
itemIds: number[];
|
||||||
insertBefore: Nullable<number>;
|
insertBefore?: Nullable<number>;
|
||||||
sessionId: Nullable<string>;
|
sessionId?: Nullable<string>;
|
||||||
})
|
})
|
||||||
// QueueSetPropertiesRequest
|
// QueueSetPropertiesRequest
|
||||||
| (MediaReqBase & {
|
| (MediaReqBase & {
|
||||||
type: "QUEUE_UPDATE";
|
type: "QUEUE_UPDATE";
|
||||||
repeatMode: Nullable<string>;
|
repeatMode?: Nullable<string>;
|
||||||
sessionId: Nullable<string>;
|
sessionId?: Nullable<string>;
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ReceiverMediaMessage =
|
export type ReceiverMediaMessage =
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import { BridgeInfo } from "./lib/bridge";
|
|||||||
import {
|
import {
|
||||||
ReceiverSelection,
|
ReceiverSelection,
|
||||||
ReceiverSelectionCast,
|
ReceiverSelectionCast,
|
||||||
ReceiverSelectionStop
|
ReceiverSelectionStop,
|
||||||
|
ReceiverSelectorMediaMessage,
|
||||||
|
ReceiverSelectorReceiverMessage
|
||||||
} from "./background/receiverSelector";
|
} from "./background/receiverSelector";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +16,7 @@ import {
|
|||||||
CastSessionUpdatedDetails,
|
CastSessionUpdatedDetails,
|
||||||
MediaStatus,
|
MediaStatus,
|
||||||
ReceiverStatus,
|
ReceiverStatus,
|
||||||
|
SenderMediaMessage,
|
||||||
SenderMessage
|
SenderMessage
|
||||||
} from "./cast/sdk/types";
|
} from "./cast/sdk/types";
|
||||||
import { SessionRequest } from "./cast/sdk/classes";
|
import { SessionRequest } from "./cast/sdk/classes";
|
||||||
@@ -55,6 +58,8 @@ type ExtMessageDefinitions = {
|
|||||||
|
|
||||||
"receiverSelector:selected": ReceiverSelection;
|
"receiverSelector:selected": ReceiverSelection;
|
||||||
"receiverSelector:stop": ReceiverSelection;
|
"receiverSelector:stop": ReceiverSelection;
|
||||||
|
"receiverSelector:receiverMessage": ReceiverSelectorReceiverMessage;
|
||||||
|
"receiverSelector:mediaMessage": ReceiverSelectorMediaMessage;
|
||||||
|
|
||||||
"main:selectReceiver": {
|
"main:selectReceiver": {
|
||||||
sessionRequest: SessionRequest;
|
sessionRequest: SessionRequest;
|
||||||
@@ -128,6 +133,23 @@ type AppMessageDefinitions = {
|
|||||||
status: MediaStatus;
|
status: MediaStatus;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sent to the bridge when non-session related receiver messages
|
||||||
|
* need to be sent (e.g. volume control, application stop, etc...).
|
||||||
|
*/
|
||||||
|
"bridge:sendReceiverMessage": {
|
||||||
|
deviceId: string;
|
||||||
|
message: SenderMessage;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Sent to the bridge when the receiver selector media UI is used
|
||||||
|
* to control media playback.
|
||||||
|
*/
|
||||||
|
"bridge:sendMediaMessage": {
|
||||||
|
deviceId: string;
|
||||||
|
message: SenderMediaMessage;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sent to bridge from cast API instance when a session request is
|
* Sent to bridge from cast API instance when a session request is
|
||||||
* initiated.
|
* initiated.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
import { SessionRequest } from "./cast/sdk/classes";
|
import { SessionRequest } from "./cast/sdk/classes";
|
||||||
import { ReceiverStatus } from "./cast/sdk/types";
|
import { MediaStatus, ReceiverStatus } from "./cast/sdk/types";
|
||||||
|
|
||||||
export enum ReceiverDeviceCapabilities {
|
export enum ReceiverDeviceCapabilities {
|
||||||
NONE = 0,
|
NONE = 0,
|
||||||
@@ -20,6 +20,7 @@ export interface ReceiverDevice {
|
|||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
status?: ReceiverStatus;
|
status?: ReceiverStatus;
|
||||||
|
mediaStatus?: MediaStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ReceiverSelectorMediaType {
|
export enum ReceiverSelectorMediaType {
|
||||||
|
|||||||
@@ -3,11 +3,14 @@
|
|||||||
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
<style>
|
<style>
|
||||||
|
path {
|
||||||
|
fill: rgba(12, 12, 13, .8);
|
||||||
|
}
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
path {
|
path {
|
||||||
fill: rgba(249, 249, 250, .8);
|
fill: rgba(249, 249, 250, .8);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<path fill="rgba(12, 12, 13, .8)" d="M8 12a1 1 0 0 1-.707-.293l-5-5a1 1 0 0 1 1.414-1.414L8 9.586l4.293-4.293a1 1 0 0 1 1.414 1.414l-5 5A1 1 0 0 1 8 12z"></path>
|
<path d="M8 12a1 1 0 0 1-.707-.293l-5-5a1 1 0 0 1 1.414-1.414L8 9.586l4.293-4.293a1 1 0 0 1 1.414 1.414l-5 5A1 1 0 0 1 8 12z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 629 B After Width: | Height: | Size: 666 B |
@@ -3,11 +3,14 @@
|
|||||||
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
<style>
|
<style>
|
||||||
|
path {
|
||||||
|
fill: rgba(12, 12, 13, .8);
|
||||||
|
}
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
path {
|
path {
|
||||||
fill: rgba(249, 249, 250, .8);
|
fill: rgba(249, 249, 250, .8);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<path fill="rgba(12, 12, 13, .8)" d="M13 11a1 1 0 0 1-.707-.293L8 6.414l-4.293 4.293a1 1 0 0 1-1.414-1.414l5-5a1 1 0 0 1 1.414 0l5 5A1 1 0 0 1 13 11z"></path>
|
<path d="M13 11a1 1 0 0 1-.707-.293L8 6.414l-4.293 4.293a1 1 0 0 1-1.414-1.414l5-5a1 1 0 0 1 1.414 0l5 5A1 1 0 0 1 13 11z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 625 B After Width: | Height: | Size: 663 B |
@@ -19,7 +19,7 @@
|
|||||||
let editingInput: HTMLInputElement;
|
let editingInput: HTMLInputElement;
|
||||||
let editingValue: string;
|
let editingValue: string;
|
||||||
|
|
||||||
let expandedItemIndices = new Set();
|
let expandedItemIndices = new Set<number>();
|
||||||
|
|
||||||
let knownAppToAdd: Nullable<KnownApp> = null;
|
let knownAppToAdd: Nullable<KnownApp> = null;
|
||||||
$: filteredKnownApps = Object.values(knownApps).filter(app => {
|
$: filteredKnownApps = Object.values(knownApps).filter(app => {
|
||||||
@@ -217,7 +217,7 @@
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="assets/{isItemExpanded
|
src="../assets/{isItemExpanded
|
||||||
? 'photon_arrowhead_up.svg'
|
? 'photon_arrowhead_up.svg'
|
||||||
: 'photon_arrowhead_down.svg'}"
|
: 'photon_arrowhead_down.svg'}"
|
||||||
alt="icon, arrow down"
|
alt="icon, arrow down"
|
||||||
|
|||||||
@@ -1,15 +1,3 @@
|
|||||||
:root {
|
|
||||||
--border-color: var(--grey-90-a20);
|
|
||||||
--secondary-color: rgb(125, 125, 125);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--border-color: var(--grey-10-a20);
|
|
||||||
--secondary-color: var(--grey-10-a60);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#root {
|
#root {
|
||||||
padding: 20px 10px;
|
padding: 20px 10px;
|
||||||
}
|
}
|
||||||
@@ -35,19 +23,6 @@ input:placeholder-shown {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
button.ghost {
|
|
||||||
width: 24px !important;
|
|
||||||
height: 24px !important;
|
|
||||||
padding: initial;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
button.ghost:not(:hover) {
|
|
||||||
background-color: initial;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form {
|
.form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -30,6 +30,9 @@
|
|||||||
--field-box-shadow-error: 0 0 0 1px var(--red-60),
|
--field-box-shadow-error: 0 0 0 1px var(--red-60),
|
||||||
0 0 0 4px var(--red-60-a30);
|
0 0 0 4px var(--red-60-a30);
|
||||||
|
|
||||||
|
--border-color: var(--grey-90-a20);
|
||||||
|
--secondary-color: rgb(125, 125, 125);
|
||||||
|
|
||||||
color-scheme: light dark;
|
color-scheme: light dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,9 +49,16 @@
|
|||||||
--field-placeholder-color: var(--grey-30);
|
--field-placeholder-color: var(--grey-30);
|
||||||
--field-border-color: var(--grey-10-a20);
|
--field-border-color: var(--grey-10-a20);
|
||||||
--field-border-color-hover: var(--grey-10-a30);
|
--field-border-color-hover: var(--grey-10-a30);
|
||||||
|
|
||||||
|
--border-color: var(--grey-10-a20);
|
||||||
|
--secondary-color: var(--grey-10-a60);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
input,
|
input,
|
||||||
textarea,
|
textarea,
|
||||||
@@ -62,34 +72,40 @@ select {
|
|||||||
background-color: var(--button-background);
|
background-color: var(--button-background);
|
||||||
color: var(--button-color);
|
color: var(--button-color);
|
||||||
}
|
}
|
||||||
button:not(:disabled):hover,
|
button:not(:disabled):hover {
|
||||||
select:not(:disabled):hover {
|
|
||||||
background-color: var(--button-background-hover);
|
background-color: var(--button-background-hover);
|
||||||
}
|
}
|
||||||
button:not(:disabled):active,
|
button:not(:disabled):active {
|
||||||
select:not(:disabled):hover:active {
|
|
||||||
background-color: var(--button-background-active);
|
background-color: var(--button-background-active);
|
||||||
}
|
}
|
||||||
|
|
||||||
input,
|
input,
|
||||||
textarea {
|
textarea,
|
||||||
|
select {
|
||||||
background-color: var(--field-background);
|
background-color: var(--field-background);
|
||||||
border: 1px solid var(--field-border-color);
|
border: 1px solid var(--field-border-color);
|
||||||
color: var(--field-color);
|
color: var(--field-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
input:hover,
|
input:hover,
|
||||||
textarea:hover {
|
textarea:hover,
|
||||||
|
select:hover {
|
||||||
border-color: var(--field-border-color-hover);
|
border-color: var(--field-border-color-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
:-moz-any(button, input, textarea, select):focus {
|
button:focus-visible,
|
||||||
|
input:focus-visible,
|
||||||
|
textarea:focus-visible,
|
||||||
|
select:focus-visible {
|
||||||
border-color: var(--focus-border-color) !important;
|
border-color: var(--focus-border-color) !important;
|
||||||
box-shadow: var(--focus-box-shadow);
|
box-shadow: var(--focus-box-shadow);
|
||||||
outline: initial;
|
outline: initial;
|
||||||
}
|
}
|
||||||
|
|
||||||
:-moz-any(button, input, textarea, select):focus::-moz-focus-inner {
|
button::-moz-focus-inner,
|
||||||
|
input::-moz-focus-inner,
|
||||||
|
textarea::-moz-focus-inner,
|
||||||
|
select::-moz-focus-inner {
|
||||||
border: initial;
|
border: initial;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +146,22 @@ button:default:hover:active {
|
|||||||
background-color: var(--button-background-primary-active);
|
background-color: var(--button-background-primary-active);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ghost {
|
||||||
|
align-items: center;
|
||||||
|
background-position: center center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
display: flex;
|
||||||
|
height: 24px !important;
|
||||||
|
justify-content: center;
|
||||||
|
padding: initial;
|
||||||
|
width: 24px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost:not(:hover),
|
||||||
|
.ghost:disabled {
|
||||||
|
background-color: initial;
|
||||||
|
}
|
||||||
|
|
||||||
.select-wrapper {
|
.select-wrapper {
|
||||||
--arrow-width: 16px;
|
--arrow-width: 16px;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -137,12 +169,16 @@ button:default:hover:active {
|
|||||||
}
|
}
|
||||||
.select-wrapper::after {
|
.select-wrapper::after {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
content: "▼";
|
background-image: url("assets/photon_arrowhead_down.svg");
|
||||||
opacity: 0.5;
|
background-position: center center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: 80%;
|
||||||
|
content: "";
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin-right: 4px;
|
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
margin-right: 4px;
|
||||||
|
opacity: 0.5;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
right: 0;
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { afterUpdate, onMount, tick } from "svelte";
|
import { afterUpdate, onMount, tick } from "svelte";
|
||||||
|
|
||||||
import LoadingIndicator from "../LoadingIndicator.svelte";
|
|
||||||
|
|
||||||
import messaging, { Message, Port } from "../../messaging";
|
import messaging, { Message, Port } from "../../messaging";
|
||||||
import options, { Options } from "../../lib/options";
|
import options, { Options } from "../../lib/options";
|
||||||
import { RemoteMatchPattern } from "../../lib/matchPattern";
|
import { RemoteMatchPattern } from "../../lib/matchPattern";
|
||||||
@@ -17,10 +15,10 @@
|
|||||||
import knownApps, { KnownApp } from "../../cast/knownApps";
|
import knownApps, { KnownApp } from "../../cast/knownApps";
|
||||||
import { hasRequiredCapabilities } from "../../cast/utils";
|
import { hasRequiredCapabilities } from "../../cast/utils";
|
||||||
|
|
||||||
const _ = browser.i18n.getMessage;
|
import Receiver from "./Receiver.svelte";
|
||||||
|
import deviceStore from "./deviceStore";
|
||||||
|
|
||||||
/** List of devices to show in receiver list. */
|
const _ = browser.i18n.getMessage;
|
||||||
let receiverDevices: ReceiverDevice[] = [];
|
|
||||||
|
|
||||||
/** Currently selected media type. */
|
/** Currently selected media type. */
|
||||||
let mediaType = ReceiverSelectorMediaType.App;
|
let mediaType = ReceiverSelectorMediaType.App;
|
||||||
@@ -40,7 +38,6 @@
|
|||||||
|
|
||||||
/** Whether casting to a device been initiated from this selector. */
|
/** Whether casting to a device been initiated from this selector. */
|
||||||
let isConnecting = false;
|
let isConnecting = false;
|
||||||
let connectingId: Nullable<string> = null;
|
|
||||||
|
|
||||||
/** Extension options */
|
/** Extension options */
|
||||||
let opts: Nullable<Options> = null;
|
let opts: Nullable<Options> = null;
|
||||||
@@ -65,6 +62,8 @@
|
|||||||
let browserWindow: Nullable<browser.windows.Window> = null;
|
let browserWindow: Nullable<browser.windows.Window> = null;
|
||||||
let resizeObserver = new ResizeObserver(() => fitWindowHeight());
|
let resizeObserver = new ResizeObserver(() => fitWindowHeight());
|
||||||
|
|
||||||
|
window.addEventListener("resize", fitWindowHeight);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
port = messaging.connect({ name: "popup" });
|
port = messaging.connect({ name: "popup" });
|
||||||
port.onMessage.addListener(onMessage);
|
port.onMessage.addListener(onMessage);
|
||||||
@@ -106,6 +105,8 @@
|
|||||||
|
|
||||||
updateKnownApp();
|
updateKnownApp();
|
||||||
|
|
||||||
|
resizeObserver.observe(document.documentElement);
|
||||||
|
|
||||||
window.addEventListener("contextmenu", onContextMenu);
|
window.addEventListener("contextmenu", onContextMenu);
|
||||||
browser.menus.onClicked.addListener(onMenuClicked);
|
browser.menus.onClicked.addListener(onMenuClicked);
|
||||||
browser.menus.onShown.addListener(onMenuShown);
|
browser.menus.onShown.addListener(onMenuShown);
|
||||||
@@ -141,7 +142,7 @@
|
|||||||
* Filter receiver devices without the required
|
* Filter receiver devices without the required
|
||||||
* capabilities.
|
* capabilities.
|
||||||
*/
|
*/
|
||||||
receiverDevices = message.data.receiverDevices.filter(device =>
|
$deviceStore = message.data.receiverDevices.filter(device =>
|
||||||
hasRequiredCapabilities(
|
hasRequiredCapabilities(
|
||||||
device,
|
device,
|
||||||
pageInfo?.sessionRequest?.capabilities
|
pageInfo?.sessionRequest?.capabilities
|
||||||
@@ -169,9 +170,11 @@
|
|||||||
function fitWindowHeight() {
|
function fitWindowHeight() {
|
||||||
if (browserWindow?.id === undefined) return;
|
if (browserWindow?.id === undefined) return;
|
||||||
browser.windows.update(browserWindow.id, {
|
browser.windows.update(browserWindow.id, {
|
||||||
height:
|
height: Math.ceil(
|
||||||
document.body.clientHeight +
|
(document.body.clientHeight +
|
||||||
(window.outerHeight - window.innerHeight)
|
(window.outerHeight - window.innerHeight)) *
|
||||||
|
window.devicePixelRatio
|
||||||
|
)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,7 +261,7 @@
|
|||||||
|
|
||||||
// Match by index rendered receiver element to device array
|
// Match by index rendered receiver element to device array
|
||||||
if (receiverElementIndex > -1) {
|
if (receiverElementIndex > -1) {
|
||||||
return receiverDevices[receiverElementIndex];
|
return $deviceStore[receiverElementIndex];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,7 +331,6 @@
|
|||||||
|
|
||||||
function onReceiverCast(receiverDevice: ReceiverDevice) {
|
function onReceiverCast(receiverDevice: ReceiverDevice) {
|
||||||
isConnecting = true;
|
isConnecting = true;
|
||||||
connectingId = receiverDevice.id;
|
|
||||||
|
|
||||||
port?.postMessage({
|
port?.postMessage({
|
||||||
subject: "receiverSelector:selected",
|
subject: "receiverSelector:selected",
|
||||||
@@ -364,86 +366,62 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="media-type-select">
|
{#if availableMediaTypes !== ReceiverSelectorMediaType.None}
|
||||||
<div class="media-type-select__label-cast">
|
<div class="media-type-select">
|
||||||
{_("popupMediaSelectCastLabel")}
|
<div class="media-type-select__label-cast">
|
||||||
</div>
|
{_("popupMediaSelectCastLabel")}
|
||||||
<div
|
</div>
|
||||||
class="select-wrapper"
|
<div class="select-wrapper">
|
||||||
class:select-wrapper--disabled={availableMediaTypes ===
|
<select class="media-type-select__dropdown" bind:value={mediaType}>
|
||||||
ReceiverSelectorMediaType.None}
|
|
||||||
>
|
|
||||||
<select
|
|
||||||
class="media-type-select__dropdown"
|
|
||||||
bind:value={mediaType}
|
|
||||||
disabled={availableMediaTypes === ReceiverSelectorMediaType.None}
|
|
||||||
>
|
|
||||||
<option
|
|
||||||
value={ReceiverSelectorMediaType.App}
|
|
||||||
disabled={!isAppMediaTypeAvailable}
|
|
||||||
>
|
|
||||||
{knownApp?.name ?? _("popupMediaTypeApp")}
|
|
||||||
</option>
|
|
||||||
|
|
||||||
{#if opts?.mirroringEnabled}
|
|
||||||
<option
|
<option
|
||||||
value={ReceiverSelectorMediaType.Tab}
|
value={ReceiverSelectorMediaType.App}
|
||||||
disabled={!(
|
disabled={!isAppMediaTypeAvailable}
|
||||||
availableMediaTypes & ReceiverSelectorMediaType.Tab
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{_("popupMediaTypeTab")}
|
{knownApp?.name ?? _("popupMediaTypeApp")}
|
||||||
</option>
|
</option>
|
||||||
<option
|
|
||||||
value={ReceiverSelectorMediaType.Screen}
|
|
||||||
disabled={!(
|
|
||||||
availableMediaTypes & ReceiverSelectorMediaType.Screen
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{_("popupMediaTypeScreen")}
|
|
||||||
</option>
|
|
||||||
{/if}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="media-type-select__label-to">
|
|
||||||
{_("popupMediaSelectToLabel")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul class="receivers">
|
{#if opts?.mirroringEnabled}
|
||||||
{#if !receiverDevices.length}
|
<option
|
||||||
<div class="receivers__not-found">
|
value={ReceiverSelectorMediaType.Tab}
|
||||||
|
disabled={!(
|
||||||
|
availableMediaTypes & ReceiverSelectorMediaType.Tab
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{_("popupMediaTypeTab")}
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value={ReceiverSelectorMediaType.Screen}
|
||||||
|
disabled={!(
|
||||||
|
availableMediaTypes &
|
||||||
|
ReceiverSelectorMediaType.Screen
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{_("popupMediaTypeScreen")}
|
||||||
|
</option>
|
||||||
|
{/if}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="media-type-select__label-to">
|
||||||
|
{_("popupMediaSelectToLabel")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<ul class="receiver-list">
|
||||||
|
{#if !$deviceStore.length}
|
||||||
|
<div class="receiver-list__not-found">
|
||||||
{_("popupNoReceiversFound")}
|
{_("popupNoReceiversFound")}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#each receiverDevices as device}
|
{#each $deviceStore as device}
|
||||||
{@const application = device.status?.applications?.[0]}
|
<Receiver
|
||||||
{@const isDeviceConnecting =
|
{port}
|
||||||
isConnecting && connectingId === device.id}
|
{device}
|
||||||
|
{isMediaTypeAvailable}
|
||||||
<li class="receiver">
|
isAnyConnecting={isConnecting}
|
||||||
<div class="receiver__name">
|
on:cast={ev => onReceiverCast(ev.detail.device)}
|
||||||
{device.friendlyName}
|
on:stop={ev => onReceiverStop(ev.detail.device)}
|
||||||
</div>
|
/>
|
||||||
<div class="receiver__address">
|
|
||||||
{application && !application.isIdleScreen
|
|
||||||
? application.statusText
|
|
||||||
: `${device.host}:${device.port}`}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
class="button receiver__connect"
|
|
||||||
on:click={() => onReceiverCast(device)}
|
|
||||||
disabled={isConnecting ||
|
|
||||||
isDeviceConnecting ||
|
|
||||||
!isMediaTypeAvailable}
|
|
||||||
>
|
|
||||||
{#if isDeviceConnecting}
|
|
||||||
{_("popupCastingButtonTitle", "")}<LoadingIndicator />
|
|
||||||
{:else}
|
|
||||||
{_("popupCastButtonTitle")}
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
179
ext/src/ui/popup/Receiver.svelte
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher, onMount } from "svelte";
|
||||||
|
|
||||||
|
import { PlayerState } from "../../cast/sdk/media/enums";
|
||||||
|
import { SenderMediaMessage, SenderMessage } from "../../cast/sdk/types";
|
||||||
|
import { ReceiverDevice } from "../../types";
|
||||||
|
|
||||||
|
import { Port } from "../../messaging";
|
||||||
|
|
||||||
|
import LoadingIndicator from "../LoadingIndicator.svelte";
|
||||||
|
import ReceiverMedia from "./ReceiverMedia.svelte";
|
||||||
|
|
||||||
|
const _ = browser.i18n.getMessage;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher<{
|
||||||
|
cast: { device: ReceiverDevice };
|
||||||
|
stop: { device: ReceiverDevice };
|
||||||
|
}>();
|
||||||
|
|
||||||
|
export let port: Nullable<Port>;
|
||||||
|
|
||||||
|
/** Whether there are sessions being established for any receiver. */
|
||||||
|
export let isAnyConnecting: boolean;
|
||||||
|
/** Whether the selected media type is available for this receiver. */
|
||||||
|
export let isMediaTypeAvailable: boolean;
|
||||||
|
|
||||||
|
/** Receiver device to display. */
|
||||||
|
export let device: ReceiverDevice;
|
||||||
|
|
||||||
|
/** Current receiver application (if available) */
|
||||||
|
$: application = device.status?.applications?.[0];
|
||||||
|
/** Current media status (if available) */
|
||||||
|
$: mediaStatus = device.mediaStatus;
|
||||||
|
|
||||||
|
let isExpanded = false;
|
||||||
|
let isConnecting = false;
|
||||||
|
|
||||||
|
function sendReceiverMessage(
|
||||||
|
partialMessage: DistributiveOmit<SenderMessage, "requestId">
|
||||||
|
) {
|
||||||
|
const message: SenderMessage = {
|
||||||
|
...partialMessage,
|
||||||
|
requestId: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
port?.postMessage({
|
||||||
|
subject: "receiverSelector:receiverMessage",
|
||||||
|
data: { deviceId: device.id, message }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function sendMediaMessage(
|
||||||
|
partialMessage: DistributiveOmit<
|
||||||
|
SenderMediaMessage,
|
||||||
|
"requestId" | "mediaSessionId"
|
||||||
|
>
|
||||||
|
) {
|
||||||
|
if (!device.mediaStatus) return;
|
||||||
|
|
||||||
|
const message: SenderMediaMessage = {
|
||||||
|
...(partialMessage as any),
|
||||||
|
requestId: 0,
|
||||||
|
mediaSessionId: device.mediaStatus.mediaSessionId
|
||||||
|
};
|
||||||
|
|
||||||
|
port?.postMessage({
|
||||||
|
subject: "receiverSelector:mediaMessage",
|
||||||
|
data: { deviceId: device.id, message }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
sendMediaMessage({
|
||||||
|
type: "GET_STATUS"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<li class="receiver">
|
||||||
|
<div class="receiver__details">
|
||||||
|
<div class="receiver__name">
|
||||||
|
{device.friendlyName}
|
||||||
|
</div>
|
||||||
|
{#if application && !application.isIdleScreen}
|
||||||
|
<div class="receiver__status">
|
||||||
|
<span class="receiver__app-name">
|
||||||
|
{application.displayName}
|
||||||
|
</span>
|
||||||
|
{#if application.statusText !== application.displayName}
|
||||||
|
· {application.statusText}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if application && !application.isIdleScreen}
|
||||||
|
<button
|
||||||
|
class="receiver__stop-button"
|
||||||
|
on:click={() => dispatch("stop", { device })}
|
||||||
|
>
|
||||||
|
{_("popupStopButtonTitle")}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="receiver__cast-button"
|
||||||
|
disabled={isConnecting || isAnyConnecting || !isMediaTypeAvailable}
|
||||||
|
on:click={() => {
|
||||||
|
isConnecting = true;
|
||||||
|
dispatch("cast", { device });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#if isConnecting}
|
||||||
|
{_("popupCastingButtonTitle", "")}<LoadingIndicator />
|
||||||
|
{:else}
|
||||||
|
{_("popupCastButtonTitle")}
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="receiver__expand-button ghost"
|
||||||
|
class:receiver__expand-button--expanded={isExpanded}
|
||||||
|
title={_("popupShowDetailsTitle")}
|
||||||
|
disabled={!mediaStatus}
|
||||||
|
on:click={() => {
|
||||||
|
isExpanded = !isExpanded;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if isExpanded}
|
||||||
|
<div class="receiver__expanded">
|
||||||
|
{#if mediaStatus}
|
||||||
|
<ReceiverMedia
|
||||||
|
status={mediaStatus}
|
||||||
|
{device}
|
||||||
|
on:togglePlayback={() => {
|
||||||
|
switch (mediaStatus?.playerState) {
|
||||||
|
case PlayerState.PLAYING:
|
||||||
|
sendMediaMessage({ type: "PAUSE" });
|
||||||
|
break;
|
||||||
|
case PlayerState.PAUSED:
|
||||||
|
sendMediaMessage({ type: "PLAY" });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
on:previous={() => {
|
||||||
|
sendMediaMessage({
|
||||||
|
type: "QUEUE_UPDATE",
|
||||||
|
jump: -1
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
on:next={() => {
|
||||||
|
sendMediaMessage({
|
||||||
|
type: "QUEUE_UPDATE",
|
||||||
|
jump: 1
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
on:seek={ev => {
|
||||||
|
sendMediaMessage({
|
||||||
|
type: "SEEK",
|
||||||
|
currentTime: ev.detail.position
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
on:trackChanged={ev => {
|
||||||
|
sendMediaMessage({
|
||||||
|
type: "EDIT_TRACKS_INFO",
|
||||||
|
activeTrackIds: ev.detail.activeTrackIds
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
on:volumeChanged={ev => {
|
||||||
|
sendReceiverMessage({
|
||||||
|
type: "SET_VOLUME",
|
||||||
|
volume: ev.detail
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
367
ext/src/ui/popup/ReceiverMedia.svelte
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher, onMount } from "svelte";
|
||||||
|
|
||||||
|
import { ReceiverDevice } from "../../types";
|
||||||
|
import { MediaStatus, _MediaCommand } from "../../cast/sdk/types";
|
||||||
|
import { Image, Volume } from "../../cast/sdk/classes";
|
||||||
|
import {
|
||||||
|
MetadataType,
|
||||||
|
PlayerState,
|
||||||
|
StreamType,
|
||||||
|
TrackType
|
||||||
|
} from "../../cast/sdk/media/enums";
|
||||||
|
|
||||||
|
const _ = browser.i18n.getMessage;
|
||||||
|
|
||||||
|
import deviceStore from "./deviceStore";
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher<{
|
||||||
|
togglePlayback: void;
|
||||||
|
seek: { position: number };
|
||||||
|
previous: void;
|
||||||
|
next: void;
|
||||||
|
trackChanged: { activeTrackIds: number[] };
|
||||||
|
volumeChanged: Partial<Volume>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
export let status: MediaStatus;
|
||||||
|
export let device: ReceiverDevice;
|
||||||
|
|
||||||
|
$: isPlayingOrPaused =
|
||||||
|
status.playerState === PlayerState.PLAYING ||
|
||||||
|
status.playerState === PlayerState.PAUSED;
|
||||||
|
|
||||||
|
let mediaTitle: Optional<string>;
|
||||||
|
let mediaSubtitle: Optional<string>;
|
||||||
|
let mediaImage: Optional<Image>;
|
||||||
|
|
||||||
|
// Choose subset of metadata depending on metadata type
|
||||||
|
$: {
|
||||||
|
const metadata = status?.media?.metadata;
|
||||||
|
|
||||||
|
mediaTitle = metadata?.title;
|
||||||
|
mediaImage = metadata?.images?.[0];
|
||||||
|
mediaSubtitle = undefined;
|
||||||
|
|
||||||
|
if (metadata) {
|
||||||
|
switch (metadata.metadataType) {
|
||||||
|
case MetadataType.AUDIOBOOK_CHAPTER:
|
||||||
|
if (metadata.bookTitle) {
|
||||||
|
metadata.title = metadata.bookTitle;
|
||||||
|
}
|
||||||
|
metadata.subtitle = metadata.chapterTitle;
|
||||||
|
break;
|
||||||
|
case MetadataType.MUSIC_TRACK:
|
||||||
|
mediaSubtitle = metadata.artist;
|
||||||
|
break;
|
||||||
|
case MetadataType.TV_SHOW:
|
||||||
|
if (metadata.seriesTitle) {
|
||||||
|
mediaTitle = metadata.seriesTitle;
|
||||||
|
mediaSubtitle = metadata.title;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case MetadataType.MOVIE:
|
||||||
|
case MetadataType.GENERIC:
|
||||||
|
mediaSubtitle = metadata.subtitle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const languageNames = new Intl.DisplayNames(
|
||||||
|
[browser.i18n.getUILanguage()],
|
||||||
|
{ type: "language" }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Subtitle/caption tracks
|
||||||
|
$: textTracks = status?.media?.tracks
|
||||||
|
?.filter(track => track.type === TrackType.TEXT)
|
||||||
|
.map(track => {
|
||||||
|
/**
|
||||||
|
* If track has no name, but does have a language, get a
|
||||||
|
* display name for the language.
|
||||||
|
*/
|
||||||
|
if (!track.name && track.language) {
|
||||||
|
try {
|
||||||
|
const displayName = languageNames.of(track.language);
|
||||||
|
if (displayName) {
|
||||||
|
track.name = displayName;
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line no-empty
|
||||||
|
} catch (err) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return track;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep track of update times for currentTime estimations
|
||||||
|
let lastUpdateTime = 0;
|
||||||
|
let currentTime = getEstimatedTime();
|
||||||
|
|
||||||
|
deviceStore.subscribe(devices => {
|
||||||
|
const newDevice = devices.find(newDevice => newDevice.id === device.id);
|
||||||
|
if (newDevice?.mediaStatus?.currentTime) {
|
||||||
|
lastUpdateTime = Date.now();
|
||||||
|
currentTime = newDevice.mediaStatus.currentTime;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update estimated time every second
|
||||||
|
onMount(() => {
|
||||||
|
const intervalId = window.setInterval(() => {
|
||||||
|
if (currentTime !== getEstimatedTime()) {
|
||||||
|
currentTime = getEstimatedTime();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(intervalId);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimates the current playback position based on the last status
|
||||||
|
* update.
|
||||||
|
*/
|
||||||
|
function getEstimatedTime() {
|
||||||
|
if (!status.currentTime) return 0;
|
||||||
|
|
||||||
|
if (status.playerState === PlayerState.PLAYING && lastUpdateTime) {
|
||||||
|
let estimatedTime =
|
||||||
|
status.currentTime + (Date.now() - lastUpdateTime) / 1000;
|
||||||
|
|
||||||
|
if (estimatedTime < 0) {
|
||||||
|
estimatedTime = 0;
|
||||||
|
} else if (
|
||||||
|
status.media?.duration &&
|
||||||
|
estimatedTime > status.media.duration
|
||||||
|
) {
|
||||||
|
estimatedTime = status.media.duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
return estimatedTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
return status.currentTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Formats seconds into HH:MM:SS */
|
||||||
|
function formatTime(seconds: number) {
|
||||||
|
const date = new Date(seconds * 1000);
|
||||||
|
const hours = date.getUTCHours();
|
||||||
|
|
||||||
|
let ret = "";
|
||||||
|
if (hours) ret += `${hours}:`;
|
||||||
|
ret += `${date.getUTCMinutes()}:`;
|
||||||
|
ret += `${date.getUTCSeconds()}`.padStart(2, "0");
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="media" style:--media-image="url({mediaImage?.url})">
|
||||||
|
{#if mediaTitle}
|
||||||
|
<div class="media__metadata">
|
||||||
|
<div class="media__title" title={mediaTitle}>
|
||||||
|
{mediaTitle}
|
||||||
|
</div>
|
||||||
|
{#if mediaSubtitle}
|
||||||
|
<div class="media__subtitle">
|
||||||
|
{mediaSubtitle}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="media__controls">
|
||||||
|
<!-- Seek bar -->
|
||||||
|
{#if status.media && status.media?.duration}
|
||||||
|
<div class="media__seek">
|
||||||
|
{#if status.media?.streamType === StreamType.LIVE}
|
||||||
|
<span class="media__live">
|
||||||
|
{_("popupMediaLive")}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<span class="media__current-time">
|
||||||
|
{formatTime(currentTime)}
|
||||||
|
</span>
|
||||||
|
{#if status.supportedMediaCommands & _MediaCommand.SEEK}
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
class="slider media__seek-bar"
|
||||||
|
class:slider--indeterminate={status.playerState ===
|
||||||
|
PlayerState.BUFFERING}
|
||||||
|
aria-label={_("popupMediaSeek")}
|
||||||
|
max={status.media.duration ?? currentTime}
|
||||||
|
value={currentTime}
|
||||||
|
on:change={ev =>
|
||||||
|
dispatch("seek", {
|
||||||
|
position: ev.currentTarget.valueAsNumber
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<progress
|
||||||
|
class="slider media__seek-bar"
|
||||||
|
class:slider--indeterminate={status.playerState ===
|
||||||
|
PlayerState.BUFFERING}
|
||||||
|
max={status.media.duration ?? currentTime}
|
||||||
|
value={currentTime}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{#if status.media.duration}
|
||||||
|
<span class="media__remaining-time">
|
||||||
|
-{formatTime(status.media?.duration - currentTime)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="media__buttons">
|
||||||
|
{#if status.supportedMediaCommands & _MediaCommand.QUEUE_PREV}
|
||||||
|
<button
|
||||||
|
class="media__previous-button ghost"
|
||||||
|
title={_("popupMediaSkipPrevious")}
|
||||||
|
on:click={() => dispatch("previous")}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{#if status.supportedMediaCommands & _MediaCommand.SEEK}
|
||||||
|
<button
|
||||||
|
class="media__backward-button ghost"
|
||||||
|
title={_("popupMediaSeekBackward")}
|
||||||
|
disabled={!isPlayingOrPaused}
|
||||||
|
on:click={() =>
|
||||||
|
dispatch("seek", { position: currentTime - 5 })}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if status.supportedMediaCommands & _MediaCommand.PAUSE}
|
||||||
|
<button
|
||||||
|
class={`ghost ${
|
||||||
|
status.playerState === PlayerState.PLAYING ||
|
||||||
|
status.playerState === PlayerState.BUFFERING
|
||||||
|
? "media__pause-button"
|
||||||
|
: "media__play-button"
|
||||||
|
}`}
|
||||||
|
title={isPlayingOrPaused &&
|
||||||
|
status.playerState === PlayerState.PLAYING
|
||||||
|
? _("popupMediaPause")
|
||||||
|
: _("popupMediaPlay")}
|
||||||
|
disabled={!isPlayingOrPaused}
|
||||||
|
on:click={() => dispatch("togglePlayback")}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if status.supportedMediaCommands & _MediaCommand.SEEK}
|
||||||
|
<button
|
||||||
|
class="media__forward-button ghost"
|
||||||
|
disabled={!isPlayingOrPaused}
|
||||||
|
title={_("popupMediaSeekForward")}
|
||||||
|
on:click={() =>
|
||||||
|
dispatch("seek", { position: currentTime + 5 })}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{#if status.supportedMediaCommands & _MediaCommand.QUEUE_NEXT}
|
||||||
|
<button
|
||||||
|
class="media__next-button ghost"
|
||||||
|
title={_("popupMediaSkipNext")}
|
||||||
|
on:click={() => dispatch("next")}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if textTracks?.length && status.supportedMediaCommands & _MediaCommand.EDIT_TRACKS}
|
||||||
|
{@const activeTextTrackId = status.activeTrackIds?.find(
|
||||||
|
trackId =>
|
||||||
|
textTracks?.find(track => track.trackId === trackId)
|
||||||
|
)}
|
||||||
|
|
||||||
|
<select
|
||||||
|
class="media__cc-button ghost"
|
||||||
|
class:media__cc-button--off={activeTextTrackId ===
|
||||||
|
undefined}
|
||||||
|
title={_("popupMediaSubtitlesClosedCaptions")}
|
||||||
|
value={activeTextTrackId}
|
||||||
|
on:change={ev => {
|
||||||
|
if (!status.activeTrackIds) return;
|
||||||
|
|
||||||
|
let activeTrackIds = status.activeTrackIds.filter(
|
||||||
|
trackId => trackId !== activeTextTrackId
|
||||||
|
);
|
||||||
|
|
||||||
|
const trackId = parseInt(ev.currentTarget.value);
|
||||||
|
if (!Number.isNaN(trackId)) {
|
||||||
|
activeTrackIds.push(trackId);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch("trackChanged", { activeTrackIds });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value={undefined}>
|
||||||
|
{_("popupMediaSubtitlesClosedCaptionsOff")}
|
||||||
|
</option>
|
||||||
|
{#each textTracks as track}
|
||||||
|
<option value={track.trackId}>
|
||||||
|
{track.name ?? track.trackId}
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Current time for unseekable live streams since seek bar
|
||||||
|
is unnecessary -->
|
||||||
|
{#if isPlayingOrPaused && !status.media?.duration}
|
||||||
|
{#if status.media?.streamType === StreamType.LIVE}
|
||||||
|
<span class="media__live">
|
||||||
|
{_("popupMediaLive")}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<span class="media__current-time">
|
||||||
|
{formatTime(currentTime)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if device.status?.volume}
|
||||||
|
{@const volume = device.status?.volume}
|
||||||
|
{@const isMuted = volume.muted || volume.level === 0}
|
||||||
|
|
||||||
|
<div class="media__volume">
|
||||||
|
<button
|
||||||
|
class="media__mute-button ghost"
|
||||||
|
class:media__mute-button--muted={isMuted}
|
||||||
|
disabled={!("muted" in volume)}
|
||||||
|
title={isMuted
|
||||||
|
? _("popupMediaUnmute")
|
||||||
|
: _("popupMediaMute")}
|
||||||
|
on:click={() => {
|
||||||
|
/**
|
||||||
|
* If not muted and volume is at 0, max out
|
||||||
|
* volume instead of flipping mute value.
|
||||||
|
*/
|
||||||
|
if (!volume.muted && volume.level === 0) {
|
||||||
|
dispatch("volumeChanged", {
|
||||||
|
level: 1
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
dispatch("volumeChanged", {
|
||||||
|
muted: !volume.muted
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
class="slider media__volume-slider"
|
||||||
|
aria-label={_("popupMediaVolume")}
|
||||||
|
disabled={!("level" in volume)}
|
||||||
|
step="0.05"
|
||||||
|
max={1}
|
||||||
|
value={volume.muted ? 0 : volume.level}
|
||||||
|
on:change={ev => {
|
||||||
|
dispatch("volumeChanged", {
|
||||||
|
level: ev.currentTarget.valueAsNumber
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
4
ext/src/ui/popup/deviceStore.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { writable } from "svelte/store";
|
||||||
|
import { ReceiverDevice } from "../../types";
|
||||||
|
|
||||||
|
export default writable<ReceiverDevice[]>([]);
|
||||||
19
ext/src/ui/popup/icons/audio-muted.svg
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
|
||||||
|
<style>
|
||||||
|
path {
|
||||||
|
fill: rgba(12, 12, 13, .8);
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
path {
|
||||||
|
fill: rgba(249, 249, 250, .8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<path d="m11 4.149 0 4.181 1.775 1.775c.3-.641.475-1.35.475-2.105a4.981 4.981 0 0 0-1.818-3.851l-.432 0z" />
|
||||||
|
<path d="M2.067 1.183a.626.626 0 0 0-.885.885L4.115 5 2 5a2 2 0 0 0-2 2l0 2a2 2 0 0 0 2 2l2.117 0 3.128 3.65C7.848 15.353 9 14.927 9 14l0-4.116 3.317 3.317c-.273.232-.56.45-.873.636a.624.624 0 0 0-.218.856.621.621 0 0 0 .856.219 7.58 7.58 0 0 0 1.122-.823l.729.729a.626.626 0 0 0 .884-.886L2.067 1.183z" />
|
||||||
|
<path d="M9 2c0-.926-1.152-1.352-1.755-.649L5.757 3.087 9 6.33 9 2z" />
|
||||||
|
<path d="M11.341 2.169a6.767 6.767 0 0 1 3.409 5.864 6.732 6.732 0 0 1-.83 3.217l.912.912A7.992 7.992 0 0 0 16 8.033a8.018 8.018 0 0 0-4.04-6.95.625.625 0 0 0-.619 1.086z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
18
ext/src/ui/popup/icons/audio-none.svg
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18px" height="18px" viewBox="0 0 18 18">
|
||||||
|
<style>
|
||||||
|
path {
|
||||||
|
fill: rgba(12, 12, 13, .8);
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
path {
|
||||||
|
fill: rgba(249, 249, 250, .8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<path d="M14.901,3.571l-4.412,3.422V1.919L6.286,5.46H4.869c-1.298,0-2.36,1.062-2.36,2.36v2.36
|
||||||
|
c0,1.062,0.708,1.888,1.652,2.242l-2.242,1.77l1.18,1.416L16.081,4.987L14.901,3.571z M10.489,16.081V11.36l-2.669,2.36
|
||||||
|
L10.489,16.081z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 788 B |
18
ext/src/ui/popup/icons/audio.svg
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
|
||||||
|
<style>
|
||||||
|
path {
|
||||||
|
fill: rgba(12, 12, 13, .8);
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
path {
|
||||||
|
fill: rgba(249, 249, 250, .8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<path d="M7.245 1.35 4.117 5 2 5a2 2 0 0 0-2 2l0 2a2 2 0 0 0 2 2l2.117 0 3.128 3.65C7.848 15.353 9 14.927 9 14L9 2c0-.927-1.152-1.353-1.755-.65z" />
|
||||||
|
<path d="M11.764 15a.623.623 0 0 1-.32-1.162 6.783 6.783 0 0 0 3.306-5.805 6.767 6.767 0 0 0-3.409-5.864.624.624 0 1 1 .619-1.085A8.015 8.015 0 0 1 16 8.033a8.038 8.038 0 0 1-3.918 6.879c-.1.06-.21.088-.318.088z" />
|
||||||
|
<path d="M11.434 11.85A4.982 4.982 0 0 0 13.25 8a4.982 4.982 0 0 0-1.819-3.852l-.431 0 0 7.702.434 0z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1011 B |
17
ext/src/ui/popup/icons/backward.svg
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<style>
|
||||||
|
path {
|
||||||
|
fill: rgba(12, 12, 13, .8);
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
path {
|
||||||
|
fill: rgba(249, 249, 250, .8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<path d="M15.996 3.995c0-.55-.386-.753-.857-.46l-6.284 3.93c-.473.295-.47.776 0 1.07l6.287 3.93c.474.296.858.08.858-.46v-8.01Z" />
|
||||||
|
<path d="M7.495 3.995c0-.55-.386-.753-.857-.46L.354 7.465c-.473.295-.47.776 0 1.07l6.287 3.93c.474.296.858.08.858-.46v-8.01Z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 797 B |
23
ext/src/ui/popup/icons/cc-off.svg
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18px" height="18px" viewBox="0 0 18 18">
|
||||||
|
<style>
|
||||||
|
path {
|
||||||
|
fill: rgba(12, 12, 13, .8);
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
path {
|
||||||
|
fill: rgba(249, 249, 250, .8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<path fill-rule="evenodd" d="M16.531,16.107H5.267l1.982-2H15c0.6,0,1-0.4,1-1V5.274
|
||||||
|
l1.946-1.964C17.963,3.399,18,3.483,18,3.576v11.031C18,15.407,17.331,16.107,16.531,16.107z M14.016,8.506h-1.218l1.005-1.014
|
||||||
|
C13.913,7.789,13.984,8.128,14.016,8.506z M11.786,12.361c-0.828,0-1.476-0.326-1.913-0.902l1.09-1.101
|
||||||
|
c0.136,0.323,0.374,0.541,0.796,0.541c0.514,0,0.695-0.44,0.756-1.014h1.535C13.908,11.43,13.071,12.361,11.786,12.361z
|
||||||
|
M1.496,16.106C0.697,16.104,0,15.406,0,14.607V3.576c0-0.8,0.7-1.5,1.5-1.5h12.846L16.299,0l1.316,1.283L2.615,17.13L1.496,16.106
|
||||||
|
z M3,4.107c-0.6,0-1,0.4-1,1v8c0,0.6,0.4,1,1,1h0.029l2.031-2.16c-0.757-0.503-1.191-1.457-1.191-2.744
|
||||||
|
c0-1.936,1.069-3.14,2.428-3.14c1.357,0,2.136,0.76,2.361,2.059l3.777-4.016H3z M8.298,8.506H7.355
|
||||||
|
c-0.047-0.623-0.49-1.23-0.99-1.23c-0.561,0-1.337,0.84-1.337,1.995c0,0.674,0.381,1.427,0.95,1.702L8.298,8.506z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
24
ext/src/ui/popup/icons/cc-on.svg
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18px" height="18px" viewBox="0 0 18 18">
|
||||||
|
<style>
|
||||||
|
path {
|
||||||
|
fill: rgba(12, 12, 13, .8);
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
path {
|
||||||
|
fill: rgba(249, 249, 250, .8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<path d="M16.531,1.984H1.5c-0.8,0-1.5,0.7-1.5,1.5v11.031c0,0.8,0.7,1.5,1.5,1.5h15.031
|
||||||
|
c0.8,0,1.469-0.7,1.469-1.5V3.484C18,2.684,17.331,1.984,16.531,1.984z
|
||||||
|
M16,13.016c0,0.6-0.4,1-1,1H3c-0.6,0-1-0.4-1-1v-8c0-0.6,0.4-1,1-1h12c0.6,0,1,0.4,1,1V13.016z
|
||||||
|
M6.426,10.807c-0.811,0-0.96-0.789-0.96-1.628c0-1.155,0.338-1.745,0.899-1.745c0.5,0,0.818,0.357,0.866,0.98
|
||||||
|
h1.484C8.585,6.877,7.785,5.972,6.297,5.972c-1.359,0-2.428,1.205-2.428,3.14c0,1.944,0.974,3.157,2.583,3.157
|
||||||
|
c1.285,0,2.153-0.93,2.295-2.476H7.244C7.183,10.367,6.94,10.807,6.426,10.807z
|
||||||
|
M11.759,10.807c-0.811,0-0.96-0.789-0.96-1.628c0-1.155,0.338-1.745,0.899-1.745c0.5,0,0.756,0.357,0.803,0.98h1.515
|
||||||
|
c-0.129-1.537-0.898-2.443-2.385-2.443c-1.359,0-2.396,1.205-2.396,3.14c0,1.944,0.943,3.157,2.552,3.157
|
||||||
|
c1.285,0,2.122-0.93,2.264-2.476h-1.535C12.454,10.367,12.273,10.807,11.759,10.807z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
17
ext/src/ui/popup/icons/forward.svg
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<style>
|
||||||
|
path {
|
||||||
|
fill: rgba(12, 12, 13, .8);
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
path {
|
||||||
|
fill: rgba(249, 249, 250, .8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<path d="M.004 12.005c0 .55.386.753.857.46l6.284-3.93c.473-.295.47-.776 0-1.07L.858 3.535C.384 3.239 0 3.455 0 3.995v8.01z" />
|
||||||
|
<path d="M8.505 12.005c0 .55.386.753.857.46l6.284-3.93c.473-.295.47-.776 0-1.07L9.36 3.535c-.474-.296-.858-.08-.858.46v8.01z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 793 B |
16
ext/src/ui/popup/icons/next.svg
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<style>
|
||||||
|
path {
|
||||||
|
fill: rgba(12, 12, 13, .8);
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
path {
|
||||||
|
fill: rgba(249, 249, 250, .8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<path d="M13.502 12.005c0 .55-.444.995-1 .995-.552 0-1-.456-1-.995v-8.01c0-.55.444-.995 1-.995.552 0 1 .456 1 .995zm-10.498 0c0 .55.386.753.857.46l6.284-3.93c.473-.295.47-.776 0-1.07l-6.287-3.93c-.474-.296-.858-.08-.858.46v8.01z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 766 B |
17
ext/src/ui/popup/icons/pause.svg
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
|
||||||
|
<style>
|
||||||
|
path {
|
||||||
|
fill: rgba(12, 12, 13, .8);
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
path {
|
||||||
|
fill: rgba(249, 249, 250, .8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<path d="m4.5 14-1 0A1.5 1.5 0 0 1 2 12.5l0-9A1.5 1.5 0 0 1 3.5 2l1 0A1.5 1.5 0 0 1 6 3.5l0 9A1.5 1.5 0 0 1 4.5 14z" />
|
||||||
|
<path d="m11.5 14-1 0A1.5 1.5 0 0 1 9 12.5l0-9A1.5 1.5 0 0 1 10.5 2l1 0A1.5 1.5 0 0 1 13 3.5l0 9a1.5 1.5 0 0 1-1.5 1.5z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 780 B |
16
ext/src/ui/popup/icons/play.svg
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
|
||||||
|
<style>
|
||||||
|
path {
|
||||||
|
fill: rgba(12, 12, 13, .8);
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
path {
|
||||||
|
fill: rgba(249, 249, 250, .8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<path d="m2.992 13.498 0-10.996a1.5 1.5 0 0 1 2.245-1.303l9.621 5.498a1.5 1.5 0 0 1 0 2.605L5.237 14.8a1.5 1.5 0 0 1-2.245-1.302z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 666 B |
16
ext/src/ui/popup/icons/previous.svg
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<style>
|
||||||
|
path {
|
||||||
|
fill: rgba(12, 12, 13, .8);
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
path {
|
||||||
|
fill: rgba(249, 249, 250, .8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<path d="M3 3.995C3 3.445 3.444 3 4 3c.552 0 1 .456 1 .995v8.01c0 .55-.444.995-1 .995-.552 0-1-.456-1-.995v-8.01zm10.498 0c0-.55-.386-.753-.857-.46l-6.284 3.93c-.473.295-.47.776 0 1.07l6.287 3.93c.474.296.858.08.858-.46v-8.01z"></path>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 769 B |
BIN
ext/src/ui/popup/icons/throbber.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
@@ -1,25 +1,17 @@
|
|||||||
body {
|
body {
|
||||||
|
--font-size: 13px;
|
||||||
background: var(--box-background);
|
background: var(--box-background);
|
||||||
color: var(--box-color);
|
color: var(--box-color);
|
||||||
margin: initial;
|
margin: initial;
|
||||||
font: message-box;
|
font: message-box;
|
||||||
font-size: 13px;
|
font-size: var(--font-size);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
[hidden] {
|
[hidden] {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.media-type-select,
|
|
||||||
.receiver:not(:last-child) {
|
|
||||||
border-bottom-color: var(--grey-50) !important;
|
|
||||||
}
|
|
||||||
.receiver__address {
|
|
||||||
color: var(--grey-10-a60) !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.whitelist-banner {
|
.whitelist-banner {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background-color: var(--blue-50-a30);
|
background-color: var(--blue-50-a30);
|
||||||
@@ -49,7 +41,7 @@ body {
|
|||||||
|
|
||||||
.media-type-select {
|
.media-type-select {
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
border-bottom: 1px solid rgba(0, 0, 0, 0.25);
|
border-bottom: 1px solid var(--border-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
margin: 0 1em;
|
margin: 0 1em;
|
||||||
padding: 0.75em 0;
|
padding: 0.75em 0;
|
||||||
@@ -66,13 +58,14 @@ body {
|
|||||||
margin-inline-start: 0.5em;
|
margin-inline-start: 0.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.receivers {
|
.receiver-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: initial;
|
margin: initial;
|
||||||
padding: initial;
|
padding: 0 1em;
|
||||||
|
padding-bottom: 0.25em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.receivers__not-found {
|
.receiver-list__not-found {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 50px;
|
height: 50px;
|
||||||
@@ -81,45 +74,273 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.receiver {
|
.receiver {
|
||||||
column-gap: 0.75em;
|
align-items: center;
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: 1fr min-content;
|
flex-wrap: wrap;
|
||||||
grid-template-rows: min-content min-content 1fr;
|
gap: 10px;
|
||||||
grid-template-areas:
|
|
||||||
"name connect"
|
|
||||||
"address connect";
|
|
||||||
justify-content: center;
|
|
||||||
margin: 0 1em;
|
|
||||||
padding: 0.75em 0;
|
padding: 0.75em 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.receiver:not(:last-child) {
|
.receiver:not(:last-child) {
|
||||||
border-bottom: 1px solid rgba(0, 0, 0, 0.25);
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.receiver__details {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.receiver__name,
|
.receiver__name,
|
||||||
.receiver__address {
|
.receiver__status {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.receiver__name {
|
.receiver__name {
|
||||||
font-size: 1.1em;
|
font-size: 1.2em;
|
||||||
grid-area: name;
|
|
||||||
}
|
|
||||||
.receiver__address {
|
|
||||||
color: GrayText;
|
|
||||||
grid-area: address;
|
|
||||||
}
|
}
|
||||||
.receiver__status {
|
.receiver__status {
|
||||||
grid-area: status;
|
color: var(--secondary-color);
|
||||||
}
|
}
|
||||||
.receiver__connect {
|
.receiver__app-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receiver__cast-button,
|
||||||
|
.receiver__stop-button {
|
||||||
align-self: center;
|
align-self: center;
|
||||||
grid-area: connect;
|
|
||||||
justify-self: end;
|
justify-self: end;
|
||||||
min-width: 100px;
|
min-width: 80px;
|
||||||
height: 32px;
|
}
|
||||||
|
|
||||||
|
.receiver__expand-button {
|
||||||
|
background-image: url("../../assets/photon_arrowhead_down.svg");
|
||||||
|
}
|
||||||
|
.receiver__expand-button--expanded {
|
||||||
|
background-image: url("../../assets/photon_arrowhead_up.svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
.receiver__expanded {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media__metadata,
|
||||||
|
.media__controls {
|
||||||
|
padding: 5px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media__metadata {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media__title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.media__subtitle {
|
||||||
|
color: var(--secondary-color);
|
||||||
|
}
|
||||||
|
.media__title,
|
||||||
|
.media__subtitle {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media__controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media__seek {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 24px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.media__seek-bar {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media__current-time,
|
||||||
|
.media__remaining-time {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-align: center;
|
||||||
|
min-width: 5ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media__live {
|
||||||
|
background: var(--box-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--box-background);
|
||||||
|
font-size: 0.85em;
|
||||||
|
font-weight: bold;
|
||||||
|
opacity: 0.75;
|
||||||
|
padding: 2px 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media__buttons {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media__buttons .ghost {
|
||||||
|
min-height: 28px;
|
||||||
|
min-width: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media__play-button {
|
||||||
|
background-image: url("../icons/play.svg");
|
||||||
|
}
|
||||||
|
.media__pause-button {
|
||||||
|
background-image: url("../icons/pause.svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
.media__previous-button {
|
||||||
|
background-image: url("../icons/previous.svg");
|
||||||
|
}
|
||||||
|
.media__backward-button {
|
||||||
|
background-image: url("../icons/backward.svg");
|
||||||
|
}
|
||||||
|
.media__forward-button {
|
||||||
|
background-image: url("../icons/forward.svg");
|
||||||
|
}
|
||||||
|
.media__next-button {
|
||||||
|
background-image: url("../icons/next.svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
.media__cc-button {
|
||||||
|
background-image: url("../icons/cc-on.svg");
|
||||||
|
border: initial;
|
||||||
|
font-size: 0;
|
||||||
|
}
|
||||||
|
.media__cc-button:hover {
|
||||||
|
background-color: var(--button-background-hover);
|
||||||
|
}
|
||||||
|
.media__cc-button:active {
|
||||||
|
background-color: var(--button-background-active);
|
||||||
|
}
|
||||||
|
.media__cc-button--off {
|
||||||
|
background-image: url("../icons/cc-off.svg");
|
||||||
|
}
|
||||||
|
.media__cc-button > option {
|
||||||
|
font-size: var(--font-size);
|
||||||
|
}
|
||||||
|
.media__cc-button > option {
|
||||||
|
background-color: var(--box-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media__mute-button {
|
||||||
|
background-image: url("../icons/audio.svg");
|
||||||
|
}
|
||||||
|
.media__mute-button--muted {
|
||||||
|
background-image: url("../icons/audio-muted.svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
.media__volume {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: inherit;
|
||||||
|
margin-inline-start: auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.media__volume-slider {
|
||||||
|
max-width: 100px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider {
|
||||||
|
--slider-track-height: 5px;
|
||||||
|
--slider-thumb-size: 13px;
|
||||||
|
--slider-fill-color: #00b6f0;
|
||||||
|
--slider-track-color: rgba(0, 0, 0, 0.7);
|
||||||
|
--slider-flare-color: rgba(255, 255, 255, 0.9);
|
||||||
|
|
||||||
|
appearance: none;
|
||||||
|
border: initial;
|
||||||
|
margin: initial;
|
||||||
|
outline: none;
|
||||||
|
padding: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider:not(:focus-visible) {
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider::-moz-range-progress,
|
||||||
|
.slider::-moz-range-track,
|
||||||
|
progress.slider {
|
||||||
|
border-radius: calc(var(--slider-track-height) / 2);
|
||||||
|
height: var(--slider-track-height);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* <input type="range"> styling */
|
||||||
|
input[type="range"].slider {
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
.slider::-moz-range-track {
|
||||||
|
background-color: var(--slider-track-color);
|
||||||
|
}
|
||||||
|
.slider::-moz-range-progress {
|
||||||
|
background-color: var(--slider-fill-color);
|
||||||
|
}
|
||||||
|
.slider::-moz-range-thumb {
|
||||||
|
background-color: currentColor;
|
||||||
|
border: initial;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: drop-shadow(0px 0px 2px rgba(0, 0, 0, 0.65));
|
||||||
|
height: var(--slider-thumb-size);
|
||||||
|
width: var(--slider-thumb-size);
|
||||||
|
}
|
||||||
|
.slider:hover::-moz-range-thumb {
|
||||||
|
background-color: #48a0f7;
|
||||||
|
}
|
||||||
|
.slider:active::-moz-range-thumb {
|
||||||
|
background-color: #2d89e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* <progress> styling */
|
||||||
|
progress.slider {
|
||||||
|
background-color: var(--slider-track-color);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.slider::-moz-progress-bar {
|
||||||
|
appearance: none;
|
||||||
|
background-color: var(--slider-fill-color);
|
||||||
|
border-radius: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes indeterminate {
|
||||||
|
from {
|
||||||
|
background-position-x: 0%;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
background-position-x: -100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.slider:indeterminate::-moz-progress-bar,
|
||||||
|
.slider.slider--indeterminate::-moz-range-progress {
|
||||||
|
animation: indeterminate 1.5s linear infinite;
|
||||||
|
background-image: repeating-linear-gradient(
|
||||||
|
to right,
|
||||||
|
transparent 0%,
|
||||||
|
var(--slider-flare-color) 25%,
|
||||||
|
transparent 50%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
}
|
}
|
||||||
|
|||||||