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 0000000..8e49fe5 Binary files /dev/null and b/ext/src/ui/popup/icons/throbber.png differ 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%; }