Add media controls (#229)

This commit is contained in:
Matt Hensman
2022-08-24 02:17:35 +01:00
committed by GitHub
parent cbc039a355
commit ac46802431
37 changed files with 1694 additions and 432 deletions

View 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();
}
}

View File

@@ -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
});
});

View File

@@ -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 =

View File

@@ -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();
}
});

View File

@@ -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;
}

View File

@@ -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.

View File

@@ -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...",

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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.

View File

@@ -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;

View File

@@ -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 =

View File

@@ -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.

View File

@@ -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 {

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>

View 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>

View 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>

View File

@@ -0,0 +1,4 @@
import { writable } from "svelte/store";
import { ReceiverDevice } from "../../types";
export default writable<ReceiverDevice[]>([]);

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -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%;
}