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();
|
||||
}
|
||||
|
||||
sendMediaMessage(message: SenderMediaMessage) {
|
||||
this.transportClient?.sendMediaMessage(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle `NS_RECEIVER` messages from the receiver device.
|
||||
* 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?.sendMediaMessage({
|
||||
type: "GET_STATUS"
|
||||
type: "GET_STATUS",
|
||||
requestId: 0
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -284,7 +284,7 @@ export interface MediaStatus {
|
||||
playerState: PlayerState;
|
||||
idleReason?: IdleReason;
|
||||
items?: QueueItem[];
|
||||
currentTime: number;
|
||||
currentTime: Nullable<number>;
|
||||
supportedMediaCommands: number;
|
||||
repeatMode: RepeatMode;
|
||||
volume: Volume;
|
||||
@@ -357,11 +357,13 @@ export type SenderMediaMessage =
|
||||
type: "MEDIA_GET_STATUS";
|
||||
mediaSessionId?: number;
|
||||
customData?: unknown;
|
||||
requestId: number;
|
||||
}
|
||||
| {
|
||||
type: "GET_STATUS";
|
||||
mediaSessionId?: number;
|
||||
customData?: unknown;
|
||||
requestId: number;
|
||||
}
|
||||
| (MediaReqBase & { type: "STOP" })
|
||||
| (MediaReqBase & { type: "MEDIA_SET_VOLUME"; volume: Volume })
|
||||
@@ -369,24 +371,24 @@ export type SenderMediaMessage =
|
||||
| (MediaReqBase & { type: "SET_PLAYBACK_RATE"; playbackRate: number })
|
||||
| (ReqBase & {
|
||||
type: "LOAD";
|
||||
activeTrackIds: Nullable<number[]>;
|
||||
activeTrackIds?: Nullable<number[]>;
|
||||
atvCredentials?: string;
|
||||
atvCredentialsType?: string;
|
||||
autoplay: Nullable<boolean>;
|
||||
currentTime: Nullable<number>;
|
||||
autoplay?: Nullable<boolean>;
|
||||
currentTime?: Nullable<number>;
|
||||
customData?: unknown;
|
||||
media: MediaInformation;
|
||||
sessionId: Nullable<string>;
|
||||
sessionId?: Nullable<string>;
|
||||
})
|
||||
| (MediaReqBase & {
|
||||
type: "SEEK";
|
||||
resumeState: Nullable<ResumeState>;
|
||||
currentTime: Nullable<number>;
|
||||
resumeState?: Nullable<ResumeState>;
|
||||
currentTime?: Nullable<number>;
|
||||
})
|
||||
| (MediaReqBase & {
|
||||
type: "EDIT_TRACKS_INFO";
|
||||
activeTrackIds: Nullable<number[]>;
|
||||
textTrackStyle: Nullable<string>;
|
||||
activeTrackIds?: Nullable<number[]>;
|
||||
textTrackStyle?: Nullable<string>;
|
||||
})
|
||||
// QueueLoadRequest
|
||||
| (MediaReqBase & {
|
||||
@@ -394,46 +396,46 @@ export type SenderMediaMessage =
|
||||
items: QueueItem[];
|
||||
startIndex: number;
|
||||
repeatMode: string;
|
||||
sessionId: Nullable<string>;
|
||||
sessionId?: Nullable<string>;
|
||||
})
|
||||
// QueueInsertItemsRequest
|
||||
| (MediaReqBase & {
|
||||
type: "QUEUE_INSERT";
|
||||
items: QueueItem[];
|
||||
insertBefore: Nullable<number>;
|
||||
sessionId: Nullable<string>;
|
||||
insertBefore?: Nullable<number>;
|
||||
sessionId?: Nullable<string>;
|
||||
})
|
||||
// QueueUpdateItemsRequest
|
||||
| (MediaReqBase & {
|
||||
type: "QUEUE_UPDATE";
|
||||
items: QueueItem[];
|
||||
sessionId: Nullable<string>;
|
||||
sessionId?: Nullable<string>;
|
||||
})
|
||||
// QueueJumpRequest
|
||||
| (MediaReqBase & {
|
||||
type: "QUEUE_UPDATE";
|
||||
jump: Nullable<number>;
|
||||
currentItemId: Nullable<number>;
|
||||
sessionId: Nullable<string>;
|
||||
jump?: Nullable<number>;
|
||||
currentItemId?: Nullable<number>;
|
||||
sessionId?: Nullable<string>;
|
||||
})
|
||||
// QueueRemoveItemsRequest
|
||||
| (MediaReqBase & {
|
||||
type: "QUEUE_REMOVE";
|
||||
itemIds: number[];
|
||||
sessionId: Nullable<string>;
|
||||
sessionId?: Nullable<string>;
|
||||
})
|
||||
// QueueReorderItemsRequest
|
||||
| (MediaReqBase & {
|
||||
type: "QUEUE_REORDER";
|
||||
itemIds: number[];
|
||||
insertBefore: Nullable<number>;
|
||||
sessionId: Nullable<string>;
|
||||
insertBefore?: Nullable<number>;
|
||||
sessionId?: Nullable<string>;
|
||||
})
|
||||
// QueueSetPropertiesRequest
|
||||
| (MediaReqBase & {
|
||||
type: "QUEUE_UPDATE";
|
||||
repeatMode: Nullable<string>;
|
||||
sessionId: Nullable<string>;
|
||||
repeatMode?: Nullable<string>;
|
||||
sessionId?: Nullable<string>;
|
||||
});
|
||||
|
||||
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 { 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 { applicationVersion } from "../../config.json";
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
stopDiscovery();
|
||||
discovery?.stop();
|
||||
stopMediaServer();
|
||||
});
|
||||
|
||||
let discovery: Discovery | null = null;
|
||||
const remotes = new Map<string, Remote>();
|
||||
|
||||
/**
|
||||
* Handle incoming messages from the extension and forward
|
||||
* them to the appropriate handlers.
|
||||
@@ -29,7 +34,75 @@ messaging.on("message", (message: Message) => {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { DecodeTransform, EncodeTransform } from "../transforms";
|
||||
import {
|
||||
MediaStatus,
|
||||
ReceiverStatus,
|
||||
SenderMediaMessage,
|
||||
SenderMessage
|
||||
} from "./components/cast/types";
|
||||
|
||||
@@ -71,6 +72,23 @@ type MessageDefinitions = {
|
||||
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
|
||||
* 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": {
|
||||
"message": "Cast...",
|
||||
|
||||
@@ -5,6 +5,7 @@ import messaging, { Port, Message } from "../messaging";
|
||||
import options from "../lib/options";
|
||||
|
||||
import { TypedEventTarget } from "../lib/TypedEventTarget";
|
||||
import { SenderMediaMessage, SenderMessage } from "../cast/sdk/types";
|
||||
import {
|
||||
ReceiverDevice,
|
||||
ReceiverSelectionActionType,
|
||||
@@ -25,13 +26,25 @@ export interface ReceiverSelectionStop {
|
||||
}
|
||||
export type ReceiverSelection = ReceiverSelectionCast | ReceiverSelectionStop;
|
||||
|
||||
export interface ReceiverSelectorReceiverMessage {
|
||||
deviceId: string;
|
||||
message: SenderMessage;
|
||||
}
|
||||
export interface ReceiverSelectorMediaMessage {
|
||||
deviceId: string;
|
||||
message: SenderMediaMessage;
|
||||
}
|
||||
|
||||
interface ReceiverSelectorEvents {
|
||||
selected: ReceiverSelectionCast;
|
||||
error: string;
|
||||
cancelled: void;
|
||||
stop: ReceiverSelectionStop;
|
||||
close: void;
|
||||
receiverMessage: ReceiverSelectorReceiverMessage;
|
||||
mediaMessage: ReceiverSelectorMediaMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages the receiver selector popup window and communication with the
|
||||
* extension page hosted within.
|
||||
@@ -100,7 +113,7 @@ export default class ReceiverSelector extends TypedEventTarget<ReceiverSelectorE
|
||||
this.availableMediaTypes = opts.availableMediaTypes;
|
||||
|
||||
const popupSizePosition = {
|
||||
width: 350,
|
||||
width: 400,
|
||||
height: 200,
|
||||
left: 100,
|
||||
top: 100
|
||||
@@ -238,6 +251,21 @@ export default class ReceiverSelector extends TypedEventTarget<ReceiverSelectorE
|
||||
|
||||
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 { 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 {
|
||||
receiverDeviceUp: { deviceInfo: ReceiverDevice };
|
||||
@@ -15,6 +21,10 @@ interface EventMap {
|
||||
deviceId: string;
|
||||
status: ReceiverStatus;
|
||||
};
|
||||
receiverDeviceMediaUpdated: {
|
||||
deviceId: string;
|
||||
status: MediaStatus;
|
||||
};
|
||||
}
|
||||
|
||||
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) => {
|
||||
switch (message.subject) {
|
||||
case "main:receiverDeviceUp": {
|
||||
@@ -111,29 +163,22 @@ export default new (class extends TypedEventTarget<EventMap> {
|
||||
|
||||
case "main:receiverDeviceStatusUpdated": {
|
||||
const { deviceId, status } = message.data;
|
||||
const receiverDevice = this.receiverDevices.get(deviceId);
|
||||
if (!receiverDevice) {
|
||||
break;
|
||||
const device = this.receiverDevices.get(deviceId);
|
||||
if (!device) break;
|
||||
|
||||
// Clear media status when app status changes
|
||||
const application = status.applications?.[0];
|
||||
if (!application || application.isIdleScreen) {
|
||||
delete device.mediaStatus;
|
||||
}
|
||||
|
||||
if (receiverDevice.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;
|
||||
}
|
||||
device.status = status;
|
||||
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("receiverDeviceUpdated", {
|
||||
detail: {
|
||||
deviceId,
|
||||
status: receiverDevice.status
|
||||
status: device.status
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -142,6 +187,28 @@ export default new (class extends TypedEventTarget<EventMap> {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,9 @@ import deviceManager from "./deviceManager";
|
||||
import ReceiverSelector, {
|
||||
ReceiverSelection,
|
||||
ReceiverSelectionCast,
|
||||
ReceiverSelectionStop
|
||||
ReceiverSelectionStop,
|
||||
ReceiverSelectorMediaMessage,
|
||||
ReceiverSelectorReceiverMessage
|
||||
} from "./ReceiverSelector";
|
||||
|
||||
import {
|
||||
@@ -122,6 +124,10 @@ async function getSelection(
|
||||
"receiverDeviceUpdated",
|
||||
onReceiverChange
|
||||
);
|
||||
deviceManager.addEventListener(
|
||||
"receiverDeviceMediaUpdated",
|
||||
onReceiverChange
|
||||
);
|
||||
|
||||
function onSelectorSelected(ev: CustomEvent<ReceiverSelectionCast>) {
|
||||
logger.info("Selected receiver", ev.detail);
|
||||
@@ -150,11 +156,27 @@ async function getSelection(
|
||||
function onSelectorError(ev: CustomEvent<string>) {
|
||||
reject(ev.detail);
|
||||
}
|
||||
function onReceiverMessage(
|
||||
ev: CustomEvent<ReceiverSelectorReceiverMessage>
|
||||
) {
|
||||
deviceManager.sendReceiverMessage(
|
||||
ev.detail.deviceId,
|
||||
ev.detail.message
|
||||
);
|
||||
}
|
||||
function onMediaMessage(ev: CustomEvent<ReceiverSelectorMediaMessage>) {
|
||||
deviceManager.sendMediaMessage(
|
||||
ev.detail.deviceId,
|
||||
ev.detail.message
|
||||
);
|
||||
}
|
||||
|
||||
sharedSelector.addEventListener("selected", onSelectorSelected);
|
||||
sharedSelector.addEventListener("stop", onSelectorStop);
|
||||
sharedSelector.addEventListener("cancelled", onSelectorCancelled);
|
||||
sharedSelector.addEventListener("error", onSelectorError);
|
||||
sharedSelector.addEventListener("receiverMessage", onReceiverMessage);
|
||||
sharedSelector.addEventListener("mediaMessage", onMediaMessage);
|
||||
sharedSelector.addEventListener("close", removeListeners);
|
||||
|
||||
function removeListeners() {
|
||||
@@ -165,6 +187,11 @@ async function getSelection(
|
||||
onSelectorCancelled
|
||||
);
|
||||
sharedSelector.removeEventListener("error", onSelectorError);
|
||||
sharedSelector.removeEventListener(
|
||||
"receiverMessage",
|
||||
onReceiverMessage
|
||||
);
|
||||
sharedSelector.removeEventListener("mediaMessage", onMediaMessage);
|
||||
sharedSelector.removeEventListener("close", removeListeners);
|
||||
|
||||
deviceManager.removeEventListener(
|
||||
@@ -179,6 +206,10 @@ async function getSelection(
|
||||
"receiverDeviceUpdated",
|
||||
onReceiverChange
|
||||
);
|
||||
deviceManager.removeEventListener(
|
||||
"receiverDeviceMediaUpdated",
|
||||
onReceiverChange
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure status manager is initialized
|
||||
|
||||
@@ -19,7 +19,8 @@ import {
|
||||
MediaStatus,
|
||||
ReceiverMediaMessage,
|
||||
SenderMediaMessage,
|
||||
SenderMessage
|
||||
SenderMessage,
|
||||
_MediaCommand
|
||||
} from "./types";
|
||||
|
||||
import { SessionStatus } from "./enums";
|
||||
@@ -29,16 +30,6 @@ import { MediaCommand } from "./media/enums";
|
||||
import { LoadRequest, QueueLoadRequest, QueueItem } from "./media/classes";
|
||||
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
|
||||
* with the existing media object, updating it with new properties.
|
||||
|
||||
@@ -15,16 +15,6 @@ import {
|
||||
UserAction
|
||||
} from "./enums";
|
||||
|
||||
export class AudiobookChapterMediaMetadata {
|
||||
bookTitle?: string;
|
||||
chapterNumber?: number;
|
||||
chapterTitle?: string;
|
||||
images?: Image[];
|
||||
subtitle?: string;
|
||||
title?: string;
|
||||
type = MetadataType.AUDIOBOOK_CHAPTER;
|
||||
}
|
||||
|
||||
export class AudiobookContainerMetadata {
|
||||
authors?: string[];
|
||||
narrators?: string[];
|
||||
@@ -71,7 +61,7 @@ export class BreakStatus {
|
||||
export class ContainerMetadata {
|
||||
containerDuration?: number;
|
||||
containerImages?: Image[];
|
||||
sections?: MediaMetadata[];
|
||||
sections?: Metadata[];
|
||||
title?: string;
|
||||
|
||||
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 {
|
||||
customData: unknown = null;
|
||||
}
|
||||
@@ -129,6 +109,7 @@ export class LoadRequest {
|
||||
}
|
||||
|
||||
export type Metadata =
|
||||
| AudiobookChapterMediaMetadata
|
||||
| GenericMediaMetadata
|
||||
| MovieMediaMetadata
|
||||
| MusicTrackMediaMetadata
|
||||
@@ -156,33 +137,60 @@ export class MediaInfo {
|
||||
constructor(public contentId: string, public contentType: string) {}
|
||||
}
|
||||
|
||||
export class MediaMetadata {
|
||||
export abstract class MediaMetadata<T extends MetadataType> {
|
||||
queueItemId?: number;
|
||||
sectionDuration?: number;
|
||||
sectionStartAbsoluteTime?: number;
|
||||
sectionStartTimeInContainer?: number;
|
||||
sectionStartTimeInMedia?: number;
|
||||
type: MetadataType;
|
||||
metadataType: MetadataType;
|
||||
type: T;
|
||||
metadataType: T;
|
||||
|
||||
constructor(type: MetadataType) {
|
||||
constructor(type: T) {
|
||||
this.type = 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[];
|
||||
metadataType = MetadataType.MOVIE;
|
||||
releaseDate?: string;
|
||||
releaseYear?: number;
|
||||
studio?: string;
|
||||
subtitle?: string;
|
||||
title?: string;
|
||||
type = MetadataType.MOVIE;
|
||||
|
||||
constructor() {
|
||||
super(MetadataType.MOVIE);
|
||||
}
|
||||
}
|
||||
|
||||
export class MusicTrackMediaMetadata {
|
||||
export class MusicTrackMediaMetadata extends MediaMetadata<MetadataType.MUSIC_TRACK> {
|
||||
albumArtist?: string;
|
||||
albumName?: string;
|
||||
artist?: string;
|
||||
@@ -190,20 +198,18 @@ export class MusicTrackMediaMetadata {
|
||||
composer?: string;
|
||||
discNumber?: number;
|
||||
images?: Image[];
|
||||
metadataType = MetadataType.MUSIC_TRACK;
|
||||
releaseDate?: string;
|
||||
releaseYear?: number;
|
||||
songName?: string;
|
||||
title?: string;
|
||||
trackNumber?: number;
|
||||
type = MetadataType.MUSIC_TRACK;
|
||||
|
||||
constructor() {
|
||||
super(MetadataType.MUSIC_TRACK);
|
||||
}
|
||||
}
|
||||
|
||||
export class PauseRequest {
|
||||
customData: unknown = null;
|
||||
}
|
||||
|
||||
export class PhotoMediaMetadata {
|
||||
export class PhotoMediaMetadata extends MediaMetadata<MetadataType.PHOTO> {
|
||||
artist?: string;
|
||||
creationDateTime?: string;
|
||||
height?: number;
|
||||
@@ -211,10 +217,33 @@ export class PhotoMediaMetadata {
|
||||
latitude?: number;
|
||||
location?: string;
|
||||
longitude?: number;
|
||||
metadataType = MetadataType.PHOTO;
|
||||
title?: string;
|
||||
type = MetadataType.PHOTO;
|
||||
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 {
|
||||
@@ -339,21 +368,6 @@ export class Track {
|
||||
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 {
|
||||
customData: unknown = null;
|
||||
|
||||
|
||||
@@ -15,15 +15,17 @@ import {
|
||||
} from "./media/enums";
|
||||
|
||||
export interface MediaStatus {
|
||||
activeTrackIds?: number[];
|
||||
currentItemId?: number;
|
||||
mediaSessionId: number;
|
||||
media?: MediaInfo;
|
||||
playbackRate: number;
|
||||
playerState: PlayerState;
|
||||
idleReason?: IdleReason;
|
||||
items?: QueueItem[];
|
||||
currentTime: number;
|
||||
currentTime: Nullable<number>;
|
||||
supportedMediaCommands: number;
|
||||
repeatMode: RepeatMode;
|
||||
repeatMode?: RepeatMode;
|
||||
volume: Volume;
|
||||
customData: unknown;
|
||||
}
|
||||
@@ -66,6 +68,23 @@ export interface CastSessionCreatedDetails extends CastSessionUpdatedDetails {
|
||||
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 {
|
||||
requestId: number;
|
||||
}
|
||||
@@ -91,77 +110,89 @@ interface MediaReqBase extends ReqBase {
|
||||
export type SenderMediaMessage =
|
||||
| (MediaReqBase & { type: "PLAY" })
|
||||
| (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: "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 })
|
||||
| (ReqBase & {
|
||||
type: "LOAD";
|
||||
activeTrackIds: Nullable<number[]>;
|
||||
activeTrackIds?: Nullable<number[]>;
|
||||
atvCredentials?: string;
|
||||
atvCredentialsType?: string;
|
||||
autoplay: Nullable<boolean>;
|
||||
currentTime: Nullable<number>;
|
||||
autoplay?: Nullable<boolean>;
|
||||
currentTime?: Nullable<number>;
|
||||
customData?: unknown;
|
||||
media: MediaInfo;
|
||||
sessionId: Nullable<string>;
|
||||
sessionId?: Nullable<string>;
|
||||
})
|
||||
| (MediaReqBase & {
|
||||
type: "SEEK";
|
||||
resumeState: Nullable<ResumeState>;
|
||||
currentTime: Nullable<number>;
|
||||
resumeState?: Nullable<ResumeState>;
|
||||
currentTime?: Nullable<number>;
|
||||
})
|
||||
| (MediaReqBase & {
|
||||
type: "EDIT_TRACKS_INFO";
|
||||
activeTrackIds: Nullable<number[]>;
|
||||
textTrackStyle: Nullable<string>;
|
||||
activeTrackIds?: Nullable<number[]>;
|
||||
textTrackStyle?: Nullable<string>;
|
||||
})
|
||||
// QueueLoadRequest
|
||||
| (ReqBase & {
|
||||
| (MediaReqBase & {
|
||||
type: "QUEUE_LOAD";
|
||||
items: QueueItem[];
|
||||
startIndex: number;
|
||||
repeatMode: string;
|
||||
sessionId: Nullable<string>;
|
||||
sessionId?: Nullable<string>;
|
||||
})
|
||||
// QueueInsertItemsRequest
|
||||
| (MediaReqBase & {
|
||||
type: "QUEUE_INSERT";
|
||||
items: QueueItem[];
|
||||
insertBefore: Nullable<number>;
|
||||
sessionId: Nullable<string>;
|
||||
insertBefore?: Nullable<number>;
|
||||
sessionId?: Nullable<string>;
|
||||
})
|
||||
// QueueUpdateItemsRequest
|
||||
| (MediaReqBase & {
|
||||
type: "QUEUE_UPDATE";
|
||||
items: QueueItem[];
|
||||
sessionId: Nullable<string>;
|
||||
sessionId?: Nullable<string>;
|
||||
})
|
||||
// QueueJumpRequest
|
||||
| (MediaReqBase & {
|
||||
type: "QUEUE_UPDATE";
|
||||
jump: Nullable<number>;
|
||||
currentItemId: Nullable<number>;
|
||||
sessionId: Nullable<string>;
|
||||
jump?: Nullable<number>;
|
||||
currentItemId?: Nullable<number>;
|
||||
sessionId?: Nullable<string>;
|
||||
})
|
||||
// QueueRemoveItemsRequest
|
||||
| (MediaReqBase & {
|
||||
type: "QUEUE_REMOVE";
|
||||
itemIds: number[];
|
||||
sessionId: Nullable<string>;
|
||||
sessionId?: Nullable<string>;
|
||||
})
|
||||
// QueueReorderItemsRequest
|
||||
| (MediaReqBase & {
|
||||
type: "QUEUE_REORDER";
|
||||
itemIds: number[];
|
||||
insertBefore: Nullable<number>;
|
||||
sessionId: Nullable<string>;
|
||||
insertBefore?: Nullable<number>;
|
||||
sessionId?: Nullable<string>;
|
||||
})
|
||||
// QueueSetPropertiesRequest
|
||||
| (MediaReqBase & {
|
||||
type: "QUEUE_UPDATE";
|
||||
repeatMode: Nullable<string>;
|
||||
sessionId: Nullable<string>;
|
||||
repeatMode?: Nullable<string>;
|
||||
sessionId?: Nullable<string>;
|
||||
});
|
||||
|
||||
export type ReceiverMediaMessage =
|
||||
|
||||
@@ -6,7 +6,9 @@ import { BridgeInfo } from "./lib/bridge";
|
||||
import {
|
||||
ReceiverSelection,
|
||||
ReceiverSelectionCast,
|
||||
ReceiverSelectionStop
|
||||
ReceiverSelectionStop,
|
||||
ReceiverSelectorMediaMessage,
|
||||
ReceiverSelectorReceiverMessage
|
||||
} from "./background/receiverSelector";
|
||||
|
||||
import {
|
||||
@@ -14,6 +16,7 @@ import {
|
||||
CastSessionUpdatedDetails,
|
||||
MediaStatus,
|
||||
ReceiverStatus,
|
||||
SenderMediaMessage,
|
||||
SenderMessage
|
||||
} from "./cast/sdk/types";
|
||||
import { SessionRequest } from "./cast/sdk/classes";
|
||||
@@ -55,6 +58,8 @@ type ExtMessageDefinitions = {
|
||||
|
||||
"receiverSelector:selected": ReceiverSelection;
|
||||
"receiverSelector:stop": ReceiverSelection;
|
||||
"receiverSelector:receiverMessage": ReceiverSelectorReceiverMessage;
|
||||
"receiverSelector:mediaMessage": ReceiverSelectorMediaMessage;
|
||||
|
||||
"main:selectReceiver": {
|
||||
sessionRequest: SessionRequest;
|
||||
@@ -128,6 +133,23 @@ type AppMessageDefinitions = {
|
||||
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
|
||||
* initiated.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use strict";
|
||||
|
||||
import { SessionRequest } from "./cast/sdk/classes";
|
||||
import { ReceiverStatus } from "./cast/sdk/types";
|
||||
import { MediaStatus, ReceiverStatus } from "./cast/sdk/types";
|
||||
|
||||
export enum ReceiverDeviceCapabilities {
|
||||
NONE = 0,
|
||||
@@ -20,6 +20,7 @@ export interface ReceiverDevice {
|
||||
host: string;
|
||||
port: number;
|
||||
status?: ReceiverStatus;
|
||||
mediaStatus?: MediaStatus;
|
||||
}
|
||||
|
||||
export enum ReceiverSelectorMediaType {
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
- 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 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>
|
||||
</svg>
|
||||
<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>
|
||||
|
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/. -->
|
||||
<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 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>
|
||||
|
Before Width: | Height: | Size: 625 B After Width: | Height: | Size: 663 B |
@@ -19,7 +19,7 @@
|
||||
let editingInput: HTMLInputElement;
|
||||
let editingValue: string;
|
||||
|
||||
let expandedItemIndices = new Set();
|
||||
let expandedItemIndices = new Set<number>();
|
||||
|
||||
let knownAppToAdd: Nullable<KnownApp> = null;
|
||||
$: filteredKnownApps = Object.values(knownApps).filter(app => {
|
||||
@@ -217,7 +217,7 @@
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="assets/{isItemExpanded
|
||||
src="../assets/{isItemExpanded
|
||||
? 'photon_arrowhead_up.svg'
|
||||
: 'photon_arrowhead_down.svg'}"
|
||||
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 {
|
||||
padding: 20px 10px;
|
||||
}
|
||||
@@ -35,19 +23,6 @@ input:placeholder-shown {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -30,6 +30,9 @@
|
||||
--field-box-shadow-error: 0 0 0 1px var(--red-60),
|
||||
0 0 0 4px var(--red-60-a30);
|
||||
|
||||
--border-color: var(--grey-90-a20);
|
||||
--secondary-color: rgb(125, 125, 125);
|
||||
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
@@ -46,9 +49,16 @@
|
||||
--field-placeholder-color: var(--grey-30);
|
||||
--field-border-color: var(--grey-10-a20);
|
||||
--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,
|
||||
input,
|
||||
textarea,
|
||||
@@ -62,34 +72,40 @@ select {
|
||||
background-color: var(--button-background);
|
||||
color: var(--button-color);
|
||||
}
|
||||
button:not(:disabled):hover,
|
||||
select:not(:disabled):hover {
|
||||
button:not(:disabled):hover {
|
||||
background-color: var(--button-background-hover);
|
||||
}
|
||||
button:not(:disabled):active,
|
||||
select:not(:disabled):hover:active {
|
||||
button:not(:disabled):active {
|
||||
background-color: var(--button-background-active);
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
textarea,
|
||||
select {
|
||||
background-color: var(--field-background);
|
||||
border: 1px solid var(--field-border-color);
|
||||
color: var(--field-color);
|
||||
}
|
||||
|
||||
input:hover,
|
||||
textarea:hover {
|
||||
textarea:hover,
|
||||
select: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;
|
||||
box-shadow: var(--focus-box-shadow);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -130,6 +146,22 @@ button:default:hover: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 {
|
||||
--arrow-width: 16px;
|
||||
position: relative;
|
||||
@@ -137,12 +169,16 @@ button:default:hover:active {
|
||||
}
|
||||
.select-wrapper::after {
|
||||
align-items: center;
|
||||
content: "▼";
|
||||
opacity: 0.5;
|
||||
background-image: url("assets/photon_arrowhead_down.svg");
|
||||
background-position: center center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 80%;
|
||||
content: "";
|
||||
display: flex;
|
||||
height: 100%;
|
||||
margin-right: 4px;
|
||||
justify-content: center;
|
||||
margin-right: 4px;
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { afterUpdate, onMount, tick } from "svelte";
|
||||
|
||||
import LoadingIndicator from "../LoadingIndicator.svelte";
|
||||
|
||||
import messaging, { Message, Port } from "../../messaging";
|
||||
import options, { Options } from "../../lib/options";
|
||||
import { RemoteMatchPattern } from "../../lib/matchPattern";
|
||||
@@ -17,10 +15,10 @@
|
||||
import knownApps, { KnownApp } from "../../cast/knownApps";
|
||||
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. */
|
||||
let receiverDevices: ReceiverDevice[] = [];
|
||||
const _ = browser.i18n.getMessage;
|
||||
|
||||
/** Currently selected media type. */
|
||||
let mediaType = ReceiverSelectorMediaType.App;
|
||||
@@ -40,7 +38,6 @@
|
||||
|
||||
/** Whether casting to a device been initiated from this selector. */
|
||||
let isConnecting = false;
|
||||
let connectingId: Nullable<string> = null;
|
||||
|
||||
/** Extension options */
|
||||
let opts: Nullable<Options> = null;
|
||||
@@ -65,6 +62,8 @@
|
||||
let browserWindow: Nullable<browser.windows.Window> = null;
|
||||
let resizeObserver = new ResizeObserver(() => fitWindowHeight());
|
||||
|
||||
window.addEventListener("resize", fitWindowHeight);
|
||||
|
||||
onMount(async () => {
|
||||
port = messaging.connect({ name: "popup" });
|
||||
port.onMessage.addListener(onMessage);
|
||||
@@ -106,6 +105,8 @@
|
||||
|
||||
updateKnownApp();
|
||||
|
||||
resizeObserver.observe(document.documentElement);
|
||||
|
||||
window.addEventListener("contextmenu", onContextMenu);
|
||||
browser.menus.onClicked.addListener(onMenuClicked);
|
||||
browser.menus.onShown.addListener(onMenuShown);
|
||||
@@ -141,7 +142,7 @@
|
||||
* Filter receiver devices without the required
|
||||
* capabilities.
|
||||
*/
|
||||
receiverDevices = message.data.receiverDevices.filter(device =>
|
||||
$deviceStore = message.data.receiverDevices.filter(device =>
|
||||
hasRequiredCapabilities(
|
||||
device,
|
||||
pageInfo?.sessionRequest?.capabilities
|
||||
@@ -169,9 +170,11 @@
|
||||
function fitWindowHeight() {
|
||||
if (browserWindow?.id === undefined) return;
|
||||
browser.windows.update(browserWindow.id, {
|
||||
height:
|
||||
document.body.clientHeight +
|
||||
(window.outerHeight - window.innerHeight)
|
||||
height: Math.ceil(
|
||||
(document.body.clientHeight +
|
||||
(window.outerHeight - window.innerHeight)) *
|
||||
window.devicePixelRatio
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -258,7 +261,7 @@
|
||||
|
||||
// Match by index rendered receiver element to device array
|
||||
if (receiverElementIndex > -1) {
|
||||
return receiverDevices[receiverElementIndex];
|
||||
return $deviceStore[receiverElementIndex];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,7 +331,6 @@
|
||||
|
||||
function onReceiverCast(receiverDevice: ReceiverDevice) {
|
||||
isConnecting = true;
|
||||
connectingId = receiverDevice.id;
|
||||
|
||||
port?.postMessage({
|
||||
subject: "receiverSelector:selected",
|
||||
@@ -364,86 +366,62 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="media-type-select">
|
||||
<div class="media-type-select__label-cast">
|
||||
{_("popupMediaSelectCastLabel")}
|
||||
</div>
|
||||
<div
|
||||
class="select-wrapper"
|
||||
class:select-wrapper--disabled={availableMediaTypes ===
|
||||
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}
|
||||
{#if availableMediaTypes !== ReceiverSelectorMediaType.None}
|
||||
<div class="media-type-select">
|
||||
<div class="media-type-select__label-cast">
|
||||
{_("popupMediaSelectCastLabel")}
|
||||
</div>
|
||||
<div class="select-wrapper">
|
||||
<select class="media-type-select__dropdown" bind:value={mediaType}>
|
||||
<option
|
||||
value={ReceiverSelectorMediaType.Tab}
|
||||
disabled={!(
|
||||
availableMediaTypes & ReceiverSelectorMediaType.Tab
|
||||
)}
|
||||
value={ReceiverSelectorMediaType.App}
|
||||
disabled={!isAppMediaTypeAvailable}
|
||||
>
|
||||
{_("popupMediaTypeTab")}
|
||||
{knownApp?.name ?? _("popupMediaTypeApp")}
|
||||
</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 !receiverDevices.length}
|
||||
<div class="receivers__not-found">
|
||||
{#if opts?.mirroringEnabled}
|
||||
<option
|
||||
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")}
|
||||
</div>
|
||||
{:else}
|
||||
{#each receiverDevices as device}
|
||||
{@const application = device.status?.applications?.[0]}
|
||||
{@const isDeviceConnecting =
|
||||
isConnecting && connectingId === device.id}
|
||||
|
||||
<li class="receiver">
|
||||
<div class="receiver__name">
|
||||
{device.friendlyName}
|
||||
</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 $deviceStore as device}
|
||||
<Receiver
|
||||
{port}
|
||||
{device}
|
||||
{isMediaTypeAvailable}
|
||||
isAnyConnecting={isConnecting}
|
||||
on:cast={ev => onReceiverCast(ev.detail.device)}
|
||||
on:stop={ev => onReceiverStop(ev.detail.device)}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
</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 {
|
||||
--font-size: 13px;
|
||||
background: var(--box-background);
|
||||
color: var(--box-color);
|
||||
margin: initial;
|
||||
font: message-box;
|
||||
font-size: 13px;
|
||||
font-size: var(--font-size);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
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 {
|
||||
align-items: center;
|
||||
background-color: var(--blue-50-a30);
|
||||
@@ -49,7 +41,7 @@ body {
|
||||
|
||||
.media-type-select {
|
||||
align-items: baseline;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.25);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
margin: 0 1em;
|
||||
padding: 0.75em 0;
|
||||
@@ -66,13 +58,14 @@ body {
|
||||
margin-inline-start: 0.5em;
|
||||
}
|
||||
|
||||
.receivers {
|
||||
.receiver-list {
|
||||
list-style: none;
|
||||
margin: initial;
|
||||
padding: initial;
|
||||
padding: 0 1em;
|
||||
padding-bottom: 0.25em;
|
||||
}
|
||||
|
||||
.receivers__not-found {
|
||||
.receiver-list__not-found {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: 50px;
|
||||
@@ -81,45 +74,273 @@ body {
|
||||
}
|
||||
|
||||
.receiver {
|
||||
column-gap: 0.75em;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr min-content;
|
||||
grid-template-rows: min-content min-content 1fr;
|
||||
grid-template-areas:
|
||||
"name connect"
|
||||
"address connect";
|
||||
justify-content: center;
|
||||
margin: 0 1em;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding: 0.75em 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.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__address {
|
||||
.receiver__status {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.receiver__name {
|
||||
font-size: 1.1em;
|
||||
grid-area: name;
|
||||
}
|
||||
.receiver__address {
|
||||
color: GrayText;
|
||||
grid-area: address;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
.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;
|
||||
grid-area: connect;
|
||||
justify-self: end;
|
||||
min-width: 100px;
|
||||
height: 32px;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.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%;
|
||||
}
|
||||
|
||||