From ac46802431dba7f1f4faa0cdf6a2452abed59556 Mon Sep 17 00:00:00 2001 From: Matt Hensman Date: Wed, 24 Aug 2022 02:17:35 +0100 Subject: [PATCH] Add media controls (#229) --- app/src/bridge/components/cast/discovery.ts | 89 +++++ app/src/bridge/components/cast/remote.ts | 7 +- app/src/bridge/components/cast/types.ts | 44 ++- app/src/bridge/components/discovery.ts | 133 ------- app/src/bridge/index.ts | 79 +++- app/src/bridge/messaging.ts | 18 + ext/src/_locales/en/messages.json | 56 +++ ext/src/background/ReceiverSelector.ts | 30 +- ext/src/background/deviceManager.ts | 101 ++++- ext/src/background/selectorManager.ts | 33 +- ext/src/cast/sdk/Session.ts | 13 +- ext/src/cast/sdk/media/classes.ts | 120 +++--- ext/src/cast/sdk/types.ts | 81 ++-- ext/src/messaging.ts | 24 +- ext/src/types.ts | 3 +- .../assets/photon_arrowhead_down.svg | 7 +- .../assets/photon_arrowhead_up.svg | 5 +- ext/src/ui/options/Whitelist.svelte | 4 +- ext/src/ui/options/styles/index.css | 25 -- ext/src/ui/photon-widgets.css | 58 ++- ext/src/ui/popup/Popup.svelte | 148 +++---- ext/src/ui/popup/Receiver.svelte | 179 +++++++++ ext/src/ui/popup/ReceiverMedia.svelte | 367 ++++++++++++++++++ ext/src/ui/popup/deviceStore.ts | 4 + ext/src/ui/popup/icons/audio-muted.svg | 19 + ext/src/ui/popup/icons/audio-none.svg | 18 + ext/src/ui/popup/icons/audio.svg | 18 + ext/src/ui/popup/icons/backward.svg | 17 + ext/src/ui/popup/icons/cc-off.svg | 23 ++ ext/src/ui/popup/icons/cc-on.svg | 24 ++ ext/src/ui/popup/icons/forward.svg | 17 + ext/src/ui/popup/icons/next.svg | 16 + ext/src/ui/popup/icons/pause.svg | 17 + ext/src/ui/popup/icons/play.svg | 16 + ext/src/ui/popup/icons/previous.svg | 16 + ext/src/ui/popup/icons/throbber.png | Bin 0 -> 30718 bytes ext/src/ui/popup/styles/index.css | 297 ++++++++++++-- 37 files changed, 1694 insertions(+), 432 deletions(-) create mode 100644 app/src/bridge/components/cast/discovery.ts delete mode 100644 app/src/bridge/components/discovery.ts rename ext/src/ui/{options => }/assets/photon_arrowhead_down.svg (68%) rename ext/src/ui/{options => }/assets/photon_arrowhead_up.svg (69%) create mode 100644 ext/src/ui/popup/Receiver.svelte create mode 100644 ext/src/ui/popup/ReceiverMedia.svelte create mode 100644 ext/src/ui/popup/deviceStore.ts create mode 100644 ext/src/ui/popup/icons/audio-muted.svg create mode 100644 ext/src/ui/popup/icons/audio-none.svg create mode 100644 ext/src/ui/popup/icons/audio.svg create mode 100644 ext/src/ui/popup/icons/backward.svg create mode 100644 ext/src/ui/popup/icons/cc-off.svg create mode 100644 ext/src/ui/popup/icons/cc-on.svg create mode 100644 ext/src/ui/popup/icons/forward.svg create mode 100644 ext/src/ui/popup/icons/next.svg create mode 100644 ext/src/ui/popup/icons/pause.svg create mode 100644 ext/src/ui/popup/icons/play.svg create mode 100644 ext/src/ui/popup/icons/previous.svg create mode 100644 ext/src/ui/popup/icons/throbber.png diff --git a/app/src/bridge/components/cast/discovery.ts b/app/src/bridge/components/cast/discovery.ts new file mode 100644 index 0000000..4ea3286 --- /dev/null +++ b/app/src/bridge/components/cast/discovery.ts @@ -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(); + } +} diff --git a/app/src/bridge/components/cast/remote.ts b/app/src/bridge/components/cast/remote.ts index d50bbe1..f44325d 100644 --- a/app/src/bridge/components/cast/remote.ts +++ b/app/src/bridge/components/cast/remote.ts @@ -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 }); }); diff --git a/app/src/bridge/components/cast/types.ts b/app/src/bridge/components/cast/types.ts index d43718b..bc2a23d 100644 --- a/app/src/bridge/components/cast/types.ts +++ b/app/src/bridge/components/cast/types.ts @@ -284,7 +284,7 @@ export interface MediaStatus { playerState: PlayerState; idleReason?: IdleReason; items?: QueueItem[]; - currentTime: number; + currentTime: Nullable; 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; + activeTrackIds?: Nullable; atvCredentials?: string; atvCredentialsType?: string; - autoplay: Nullable; - currentTime: Nullable; + autoplay?: Nullable; + currentTime?: Nullable; customData?: unknown; media: MediaInformation; - sessionId: Nullable; + sessionId?: Nullable; }) | (MediaReqBase & { type: "SEEK"; - resumeState: Nullable; - currentTime: Nullable; + resumeState?: Nullable; + currentTime?: Nullable; }) | (MediaReqBase & { type: "EDIT_TRACKS_INFO"; - activeTrackIds: Nullable; - textTrackStyle: Nullable; + activeTrackIds?: Nullable; + textTrackStyle?: Nullable; }) // QueueLoadRequest | (MediaReqBase & { @@ -394,46 +396,46 @@ export type SenderMediaMessage = items: QueueItem[]; startIndex: number; repeatMode: string; - sessionId: Nullable; + sessionId?: Nullable; }) // QueueInsertItemsRequest | (MediaReqBase & { type: "QUEUE_INSERT"; items: QueueItem[]; - insertBefore: Nullable; - sessionId: Nullable; + insertBefore?: Nullable; + sessionId?: Nullable; }) // QueueUpdateItemsRequest | (MediaReqBase & { type: "QUEUE_UPDATE"; items: QueueItem[]; - sessionId: Nullable; + sessionId?: Nullable; }) // QueueJumpRequest | (MediaReqBase & { type: "QUEUE_UPDATE"; - jump: Nullable; - currentItemId: Nullable; - sessionId: Nullable; + jump?: Nullable; + currentItemId?: Nullable; + sessionId?: Nullable; }) // QueueRemoveItemsRequest | (MediaReqBase & { type: "QUEUE_REMOVE"; itemIds: number[]; - sessionId: Nullable; + sessionId?: Nullable; }) // QueueReorderItemsRequest | (MediaReqBase & { type: "QUEUE_REORDER"; itemIds: number[]; - insertBefore: Nullable; - sessionId: Nullable; + insertBefore?: Nullable; + sessionId?: Nullable; }) // QueueSetPropertiesRequest | (MediaReqBase & { type: "QUEUE_UPDATE"; - repeatMode: Nullable; - sessionId: Nullable; + repeatMode?: Nullable; + sessionId?: Nullable; }); export type ReceiverMediaMessage = diff --git a/app/src/bridge/components/discovery.ts b/app/src/bridge/components/discovery.ts deleted file mode 100644 index 17e8165..0000000 --- a/app/src/bridge/components/discovery.ts +++ /dev/null @@ -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(); - -/** - * 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(); - } -}); diff --git a/app/src/bridge/index.ts b/app/src/bridge/index.ts index c63d94d..9369d77 100755 --- a/app/src/bridge/index.ts +++ b/app/src/bridge/index.ts @@ -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(); + /** * 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; } diff --git a/app/src/bridge/messaging.ts b/app/src/bridge/messaging.ts index d43444e..d8b4fd2 100644 --- a/app/src/bridge/messaging.ts +++ b/app/src/bridge/messaging.ts @@ -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. diff --git a/ext/src/_locales/en/messages.json b/ext/src/_locales/en/messages.json index 2942118..1677c80 100755 --- a/ext/src/_locales/en/messages.json +++ b/ext/src/_locales/en/messages.json @@ -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...", diff --git a/ext/src/background/ReceiverSelector.ts b/ext/src/background/ReceiverSelector.ts index da8d27f..ad16569 100644 --- a/ext/src/background/ReceiverSelector.ts +++ b/ext/src/background/ReceiverSelector.ts @@ -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 { @@ -79,6 +89,48 @@ export default new (class extends TypedEventTarget { } } + 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 { 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 { } 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; } } diff --git a/ext/src/background/selectorManager.ts b/ext/src/background/selectorManager.ts index 16e7e2f..5f9731a 100644 --- a/ext/src/background/selectorManager.ts +++ b/ext/src/background/selectorManager.ts @@ -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) { logger.info("Selected receiver", ev.detail); @@ -150,11 +156,27 @@ async function getSelection( function onSelectorError(ev: CustomEvent) { reject(ev.detail); } + function onReceiverMessage( + ev: CustomEvent + ) { + deviceManager.sendReceiverMessage( + ev.detail.deviceId, + ev.detail.message + ); + } + function onMediaMessage(ev: CustomEvent) { + 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 diff --git a/ext/src/cast/sdk/Session.ts b/ext/src/cast/sdk/Session.ts index 116eeda..7ff7885 100644 --- a/ext/src/cast/sdk/Session.ts +++ b/ext/src/cast/sdk/Session.ts @@ -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. diff --git a/ext/src/cast/sdk/media/classes.ts b/ext/src/cast/sdk/media/classes.ts index f143e32..dac72ba 100644 --- a/ext/src/cast/sdk/media/classes.ts +++ b/ext/src/cast/sdk/media/classes.ts @@ -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 { 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 { + bookTitle?: string; + chapterNumber?: number; + chapterTitle?: string; + images?: Image[]; + subtitle?: string; + title?: string; + + constructor() { + super(MetadataType.AUDIOBOOK_CHAPTER); + } +} + +export class GenericMediaMetadata extends MediaMetadata { + images?: Image[]; + releaseDate?: string; + releaseYear?: number; + subtitle?: string; + title?: string; + + constructor() { + super(MetadataType.GENERIC); + } +} + +export class MovieMediaMetadata extends MediaMetadata { 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 { 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 { 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 { + 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; diff --git a/ext/src/cast/sdk/types.ts b/ext/src/cast/sdk/types.ts index a8236f3..19fd93b 100644 --- a/ext/src/cast/sdk/types.ts +++ b/ext/src/cast/sdk/types.ts @@ -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; 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 }) + | (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; + activeTrackIds?: Nullable; atvCredentials?: string; atvCredentialsType?: string; - autoplay: Nullable; - currentTime: Nullable; + autoplay?: Nullable; + currentTime?: Nullable; customData?: unknown; media: MediaInfo; - sessionId: Nullable; + sessionId?: Nullable; }) | (MediaReqBase & { type: "SEEK"; - resumeState: Nullable; - currentTime: Nullable; + resumeState?: Nullable; + currentTime?: Nullable; }) | (MediaReqBase & { type: "EDIT_TRACKS_INFO"; - activeTrackIds: Nullable; - textTrackStyle: Nullable; + activeTrackIds?: Nullable; + textTrackStyle?: Nullable; }) // QueueLoadRequest - | (ReqBase & { + | (MediaReqBase & { type: "QUEUE_LOAD"; items: QueueItem[]; startIndex: number; repeatMode: string; - sessionId: Nullable; + sessionId?: Nullable; }) // QueueInsertItemsRequest | (MediaReqBase & { type: "QUEUE_INSERT"; items: QueueItem[]; - insertBefore: Nullable; - sessionId: Nullable; + insertBefore?: Nullable; + sessionId?: Nullable; }) // QueueUpdateItemsRequest | (MediaReqBase & { type: "QUEUE_UPDATE"; items: QueueItem[]; - sessionId: Nullable; + sessionId?: Nullable; }) // QueueJumpRequest | (MediaReqBase & { type: "QUEUE_UPDATE"; - jump: Nullable; - currentItemId: Nullable; - sessionId: Nullable; + jump?: Nullable; + currentItemId?: Nullable; + sessionId?: Nullable; }) // QueueRemoveItemsRequest | (MediaReqBase & { type: "QUEUE_REMOVE"; itemIds: number[]; - sessionId: Nullable; + sessionId?: Nullable; }) // QueueReorderItemsRequest | (MediaReqBase & { type: "QUEUE_REORDER"; itemIds: number[]; - insertBefore: Nullable; - sessionId: Nullable; + insertBefore?: Nullable; + sessionId?: Nullable; }) // QueueSetPropertiesRequest | (MediaReqBase & { type: "QUEUE_UPDATE"; - repeatMode: Nullable; - sessionId: Nullable; + repeatMode?: Nullable; + sessionId?: Nullable; }); export type ReceiverMediaMessage = diff --git a/ext/src/messaging.ts b/ext/src/messaging.ts index 7429b12..0e2bffb 100644 --- a/ext/src/messaging.ts +++ b/ext/src/messaging.ts @@ -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. diff --git a/ext/src/types.ts b/ext/src/types.ts index 7c24645..509d668 100644 --- a/ext/src/types.ts +++ b/ext/src/types.ts @@ -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 { diff --git a/ext/src/ui/options/assets/photon_arrowhead_down.svg b/ext/src/ui/assets/photon_arrowhead_down.svg similarity index 68% rename from ext/src/ui/options/assets/photon_arrowhead_down.svg rename to ext/src/ui/assets/photon_arrowhead_down.svg index 6df37f8..540603e 100644 --- a/ext/src/ui/options/assets/photon_arrowhead_down.svg +++ b/ext/src/ui/assets/photon_arrowhead_down.svg @@ -3,11 +3,14 @@ - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> - - + + \ No newline at end of file diff --git a/ext/src/ui/options/assets/photon_arrowhead_up.svg b/ext/src/ui/assets/photon_arrowhead_up.svg similarity index 69% rename from ext/src/ui/options/assets/photon_arrowhead_up.svg rename to ext/src/ui/assets/photon_arrowhead_up.svg index ad272f6..46435e2 100644 --- a/ext/src/ui/options/assets/photon_arrowhead_up.svg +++ b/ext/src/ui/assets/photon_arrowhead_up.svg @@ -3,11 +3,14 @@ - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> - + \ No newline at end of file diff --git a/ext/src/ui/options/Whitelist.svelte b/ext/src/ui/options/Whitelist.svelte index 9a0e762..2446044 100644 --- a/ext/src/ui/options/Whitelist.svelte +++ b/ext/src/ui/options/Whitelist.svelte @@ -19,7 +19,7 @@ let editingInput: HTMLInputElement; let editingValue: string; - let expandedItemIndices = new Set(); + let expandedItemIndices = new Set(); let knownAppToAdd: Nullable = null; $: filteredKnownApps = Object.values(knownApps).filter(app => { @@ -217,7 +217,7 @@ }} > icon, arrow down 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 = null; /** Extension options */ let opts: Nullable = null; @@ -65,6 +62,8 @@ let browserWindow: Nullable = 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 @@ -
-
- {_("popupMediaSelectCastLabel")} -
-
- - - {/if} - -
-
- {_("popupMediaSelectToLabel")} -
-
-
    - {#if !receiverDevices.length} -
    + {#if opts?.mirroringEnabled} + + + {/if} + +
    +
    + {_("popupMediaSelectToLabel")} +
    + +{/if} + +
      + {#if !$deviceStore.length} +
      {_("popupNoReceiversFound")}
      {:else} - {#each receiverDevices as device} - {@const application = device.status?.applications?.[0]} - {@const isDeviceConnecting = - isConnecting && connectingId === device.id} - -
    • -
      - {device.friendlyName} -
      -
      - {application && !application.isIdleScreen - ? application.statusText - : `${device.host}:${device.port}`} -
      - -
    • + {#each $deviceStore as device} + onReceiverCast(ev.detail.device)} + on:stop={ev => onReceiverStop(ev.detail.device)} + /> {/each} {/if}
    diff --git a/ext/src/ui/popup/Receiver.svelte b/ext/src/ui/popup/Receiver.svelte new file mode 100644 index 0000000..fd9a8c3 --- /dev/null +++ b/ext/src/ui/popup/Receiver.svelte @@ -0,0 +1,179 @@ + + +
  • +
    +
    + {device.friendlyName} +
    + {#if application && !application.isIdleScreen} +
    + + {application.displayName} + + {#if application.statusText !== application.displayName} + · {application.statusText} + {/if} +
    + {/if} +
    + {#if application && !application.isIdleScreen} + + {:else} + + {/if} + +
  • diff --git a/ext/src/ui/popup/ReceiverMedia.svelte b/ext/src/ui/popup/ReceiverMedia.svelte new file mode 100644 index 0000000..efe981a --- /dev/null +++ b/ext/src/ui/popup/ReceiverMedia.svelte @@ -0,0 +1,367 @@ + + +
    + {#if mediaTitle} + + {/if} + +
    + + {#if status.media && status.media?.duration} +
    + {#if status.media?.streamType === StreamType.LIVE} + + {_("popupMediaLive")} + + {/if} + + {formatTime(currentTime)} + + {#if status.supportedMediaCommands & _MediaCommand.SEEK} + + dispatch("seek", { + position: ev.currentTarget.valueAsNumber + })} + /> + {:else} + + {/if} + {#if status.media.duration} + + -{formatTime(status.media?.duration - currentTime)} + + {/if} +
    + {/if} + +
    + {#if status.supportedMediaCommands & _MediaCommand.QUEUE_PREV} +
    + {/if} +
    +
    + diff --git a/ext/src/ui/popup/deviceStore.ts b/ext/src/ui/popup/deviceStore.ts new file mode 100644 index 0000000..1c8da78 --- /dev/null +++ b/ext/src/ui/popup/deviceStore.ts @@ -0,0 +1,4 @@ +import { writable } from "svelte/store"; +import { ReceiverDevice } from "../../types"; + +export default writable([]); diff --git a/ext/src/ui/popup/icons/audio-muted.svg b/ext/src/ui/popup/icons/audio-muted.svg new file mode 100644 index 0000000..ad67e06 --- /dev/null +++ b/ext/src/ui/popup/icons/audio-muted.svg @@ -0,0 +1,19 @@ + + + + + + + + \ No newline at end of file diff --git a/ext/src/ui/popup/icons/audio-none.svg b/ext/src/ui/popup/icons/audio-none.svg new file mode 100644 index 0000000..764b0a0 --- /dev/null +++ b/ext/src/ui/popup/icons/audio-none.svg @@ -0,0 +1,18 @@ + + + + + \ No newline at end of file diff --git a/ext/src/ui/popup/icons/audio.svg b/ext/src/ui/popup/icons/audio.svg new file mode 100644 index 0000000..d79c5af --- /dev/null +++ b/ext/src/ui/popup/icons/audio.svg @@ -0,0 +1,18 @@ + + + + + + + \ No newline at end of file diff --git a/ext/src/ui/popup/icons/backward.svg b/ext/src/ui/popup/icons/backward.svg new file mode 100644 index 0000000..2207624 --- /dev/null +++ b/ext/src/ui/popup/icons/backward.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/ext/src/ui/popup/icons/cc-off.svg b/ext/src/ui/popup/icons/cc-off.svg new file mode 100644 index 0000000..fda29d6 --- /dev/null +++ b/ext/src/ui/popup/icons/cc-off.svg @@ -0,0 +1,23 @@ + + + + + \ No newline at end of file diff --git a/ext/src/ui/popup/icons/cc-on.svg b/ext/src/ui/popup/icons/cc-on.svg new file mode 100644 index 0000000..27edc59 --- /dev/null +++ b/ext/src/ui/popup/icons/cc-on.svg @@ -0,0 +1,24 @@ + + + + + \ No newline at end of file diff --git a/ext/src/ui/popup/icons/forward.svg b/ext/src/ui/popup/icons/forward.svg new file mode 100644 index 0000000..34a043e --- /dev/null +++ b/ext/src/ui/popup/icons/forward.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/ext/src/ui/popup/icons/next.svg b/ext/src/ui/popup/icons/next.svg new file mode 100644 index 0000000..748994f --- /dev/null +++ b/ext/src/ui/popup/icons/next.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/ext/src/ui/popup/icons/pause.svg b/ext/src/ui/popup/icons/pause.svg new file mode 100644 index 0000000..8a8a7d2 --- /dev/null +++ b/ext/src/ui/popup/icons/pause.svg @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/ext/src/ui/popup/icons/play.svg b/ext/src/ui/popup/icons/play.svg new file mode 100644 index 0000000..339d650 --- /dev/null +++ b/ext/src/ui/popup/icons/play.svg @@ -0,0 +1,16 @@ + + + + + \ No newline at end of file diff --git a/ext/src/ui/popup/icons/previous.svg b/ext/src/ui/popup/icons/previous.svg new file mode 100644 index 0000000..71838e8 --- /dev/null +++ b/ext/src/ui/popup/icons/previous.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/ext/src/ui/popup/icons/throbber.png b/ext/src/ui/popup/icons/throbber.png new file mode 100644 index 0000000000000000000000000000000000000000..8e49fe5b2acbb16b54acae902cc89c4050896517 GIT binary patch literal 30718 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 literal 0 HcmV?d00001 diff --git a/ext/src/ui/popup/styles/index.css b/ext/src/ui/popup/styles/index.css index d6ec078..23c78e2 100755 --- a/ext/src/ui/popup/styles/index.css +++ b/ext/src/ui/popup/styles/index.css @@ -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); +} + +/* 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; +} + +/* 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%; }