diff --git a/ext/src/_locales/en/messages.json b/ext/src/_locales/en/messages.json index 1677c80..e6c0b21 100755 --- a/ext/src/_locales/en/messages.json +++ b/ext/src/_locales/en/messages.json @@ -78,7 +78,7 @@ "description": "Alternate action button text displayed instead of popupCastButtonTitle." }, "popupCastMenuTitle": { - "message": "Cast to \"$deviceName$\"", + "message": "Cast to $deviceName$", "description": "Menu text for cast item in context menu for receivers in receiver selector.", "placeholders": { "deviceName": { @@ -88,7 +88,7 @@ } }, "popupStopMenuTitle": { - "message": "Stop \"$appName$\" on \"$deviceName$\"", + "message": "Stop \"$appName$\" on $deviceName$", "description": "Menu text for stop item in context menu for receivers in receiver selector.", "placeholders": { "appName": { @@ -133,11 +133,11 @@ "message": "Seek forwards", "description": "Media controls seek forward button title." }, - "popupMediaSubtitlesClosedCaptions": { - "message": "Subtitles/closed captions", + "popupMediaSubtitlesCaptions": { + "message": "Subtitles/captions", "description": "Media controls subtitles/cc button title." }, - "popupMediaSubtitlesClosedCaptionsOff": { + "popupMediaSubtitlesCaptionsOff": { "message": "Off", "description": "Media controls subtitles/cc button title." }, diff --git a/ext/src/background/menus.ts b/ext/src/background/menus.ts index 3248f8a..a9dc515 100644 --- a/ext/src/background/menus.ts +++ b/ext/src/background/menus.ts @@ -9,6 +9,8 @@ import { ReceiverSelectorMediaType } from "../types"; import ReceiverSelector, { ReceiverSelection } from "./ReceiverSelector"; import castManager from "./castManager"; +import * as menuIds from "../menuIds"; + const _ = browser.i18n.getMessage; const URL_PATTERN_HTTP = "http://*/*"; @@ -69,16 +71,51 @@ export async function initMenus() { }); // Popup context menus - const popupUrlPattern = `${browser.runtime.getURL("ui/popup")}/*`; - browser.menus.create({ - id: "popup_cast", - title: _("popupCastButtonTitle"), - documentUrlPatterns: [popupUrlPattern] + const createPopupMenu = (props: browser.menus._CreateCreateProperties) => + browser.menus.create({ + visible: false, + documentUrlPatterns: [`${browser.runtime.getURL("ui/popup")}/*`], + ...props + }); + + createPopupMenu({ + id: menuIds.POPUP_MEDIA_PLAY_PAUSE, + title: _("popupMediaPlay") }); - browser.menus.create({ - id: "popup_stop", - title: _("popupStopButtonTitle"), - documentUrlPatterns: [popupUrlPattern] + createPopupMenu({ + id: menuIds.POPUP_MEDIA_MUTE, + type: "checkbox", + title: _("popupMediaMute") + }); + createPopupMenu({ + id: menuIds.POPUP_MEDIA_SKIP_PREVIOUS, + title: _("popupMediaSkipPrevious") + }); + createPopupMenu({ + id: menuIds.POPUP_MEDIA_SKIP_NEXT, + title: _("popupMediaSkipNext") + }); + createPopupMenu({ + id: menuIds.POPUP_MEDIA_CC, + title: _("popupMediaSubtitlesCaptions") + }); + createPopupMenu({ + id: menuIds.POPUP_MEDIA_CC_OFF, + parentId: menuIds.POPUP_MEDIA_CC, + type: "radio", + title: _("popupMediaSubtitlesCaptionsOff") + }); + + createPopupMenu({ id: menuIds.POPUP_MEDIA_SEPARATOR, type: "separator" }); + + createPopupMenu({ + id: menuIds.POPUP_CAST, + title: _("popupCastButtonTitle"), + icons: { 16: "icons/icon.svg" } + }); + createPopupMenu({ + id: menuIds.POPUP_STOP, + title: _("popupStopButtonTitle") }); browser.menus.onShown.addListener(onMenuShown); diff --git a/ext/src/menuIds.ts b/ext/src/menuIds.ts new file mode 100644 index 0000000..3cecf70 --- /dev/null +++ b/ext/src/menuIds.ts @@ -0,0 +1,30 @@ +export const POPUP_CAST = "popupCastMenuId"; +export const POPUP_STOP = "popupStopMenuId"; + +export const POPUP_MEDIA_SEPARATOR = "popupMediaSeparatorMenuId"; +export const POPUP_MEDIA_PLAY_PAUSE = "popupMediaPlayPauseMenuId"; +export const POPUP_MEDIA_MUTE = "popupMediaMuteMenuId"; +export const POPUP_MEDIA_SKIP_PREVIOUS = "popupMediaSkipPreviousMenuId"; +export const POPUP_MEDIA_SKIP_NEXT = "popupMediaSkipNextMenuId"; +export const POPUP_MEDIA_CC = "popupMediaSubtitlesCaptionsMenuId"; +export const POPUP_MEDIA_CC_OFF = "popupMediaSubtitlesCaptionsOffMenuId"; + +export const receiverMenuIds = [ + POPUP_CAST, + POPUP_STOP, + POPUP_MEDIA_SEPARATOR, + POPUP_MEDIA_PLAY_PAUSE, + POPUP_MEDIA_MUTE, + POPUP_MEDIA_SKIP_PREVIOUS, + POPUP_MEDIA_SKIP_NEXT, + POPUP_MEDIA_CC +]; + +export const mediaMenuIds = [ + POPUP_MEDIA_SEPARATOR, + POPUP_MEDIA_PLAY_PAUSE, + POPUP_MEDIA_MUTE, + POPUP_MEDIA_SKIP_PREVIOUS, + POPUP_MEDIA_SKIP_NEXT, + POPUP_MEDIA_CC +]; diff --git a/ext/src/ui/popup/Popup.svelte b/ext/src/ui/popup/Popup.svelte index 3155db4..785a6c6 100644 --- a/ext/src/ui/popup/Popup.svelte +++ b/ext/src/ui/popup/Popup.svelte @@ -5,6 +5,8 @@ import options, { Options } from "../../lib/options"; import { RemoteMatchPattern } from "../../lib/matchPattern"; + import { receiverMenuIds } from "../../menuIds"; + import { ReceiverDevice, ReceiverDeviceCapabilities, @@ -128,8 +130,6 @@ resizeObserver.observe(document.documentElement); - window.addEventListener("contextmenu", onContextMenu); - browser.menus.onClicked.addListener(onMenuClicked); browser.menus.onShown.addListener(onMenuShown); }); @@ -137,8 +137,6 @@ port?.disconnect(); resizeObserver.disconnect(); - window.removeEventListener("contextmenu", onContextMenu); - browser.menus.onClicked.removeListener(onMenuClicked); browser.menus.onShown.removeListener(onMenuShown); }); @@ -247,93 +245,27 @@ } } - function onContextMenu(ev: MouseEvent) { - if (!(ev.target instanceof Element)) return; - - const receiverElement = ev.target.closest(".receiver"); - if (receiverElement) { - browser.menus.overrideContext({ - showDefaults: false - }); - } - } - - function getDeviceFromElement(target: Element) { - const receiverElement = target.closest(".receiver"); - if (!receiverElement) return; - - const receiverElementIndex = [ - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - ...receiverElement.parentElement!.children - ].indexOf(receiverElement); - - // Match by index rendered receiver element to device array - if (receiverElementIndex > -1) { - return devices[receiverElementIndex]; - } - } + /** Device ID associated with the last receiver menu that was shown. */ + let lastMenuShownDeviceId: string; /** Handle show events for receiver context menus. */ function onMenuShown(info: browser.menus._OnShownInfo) { - if (!info.targetElementId) return; - const target = browser.menus.getTargetElement(info.targetElementId); - if (!target) return; - - const device = getDeviceFromElement(target); - if (!device) { - browser.menus.update("popup_cast", { visible: false }); - browser.menus.update("popup_stop", { visible: false }); - } else { - const app = device.status?.applications?.[0]; - const isAppRunning = !!(app && !app.isIdleScreen); - - browser.menus.update("popup_cast", { - visible: true, - title: _("popupCastMenuTitle", device.friendlyName), - enabled: - // Not already connecting to a receiver - !isConnecting && - // Selected media type available - isMediaTypeAvailable - }); - - browser.menus.update("popup_stop", { - visible: isAppRunning, - title: isAppRunning - ? _("popupStopMenuTitle", [ - app.displayName, - device.friendlyName - ]) - : "" - }); - } - - browser.menus.refresh(); - } - - /** Handle click events for receiver context menus. */ - function onMenuClicked(info: browser.menus.OnClickData) { - if ( - info.menuItemId !== "popup_cast" && - info.menuItemId !== "popup_stop" - ) { - return; - } + // Only handle menu events on this page + if (info.pageUrl !== window.location.href) return; if (!info.targetElementId) return; - const target = browser.menus.getTargetElement(info.targetElementId); - if (!target) return; + const targetElement = browser.menus.getTargetElement( + info.targetElementId + ); + if (!targetElement) return; - const device = getDeviceFromElement(target); - if (!device) return; + const receiverElement = targetElement.closest(".receiver"); + if (!receiverElement) { + for (const menuId of receiverMenuIds) { + browser.menus.update(menuId, { visible: false }); + } - switch (info.menuItemId) { - case "popup_cast": - onReceiverCast(device); - break; - case "popup_stop": - onReceiverStop(device); - break; + browser.menus.refresh(); } } @@ -434,6 +366,7 @@ ReceiverSelectorMediaType.None && isDeviceCompatible(device)} isAnyConnecting={isConnecting} + bind:lastMenuShownDeviceId on:cast={ev => onReceiverCast(ev.detail.device)} on:stop={ev => onReceiverStop(ev.detail.device)} /> diff --git a/ext/src/ui/popup/Receiver.svelte b/ext/src/ui/popup/Receiver.svelte index fc96e40..3a7402a 100644 --- a/ext/src/ui/popup/Receiver.svelte +++ b/ext/src/ui/popup/Receiver.svelte @@ -4,10 +4,14 @@ import { ReceiverDevice, ReceiverDeviceCapabilities } from "../../types"; import type { Port } from "../../messaging"; - import { PlayerState } from "../../cast/sdk/media/enums"; - import type { + import * as menuIds from "../../menuIds"; + + import type { Volume } from "../../cast/sdk/classes"; + import { PlayerState, TrackType } from "../../cast/sdk/media/enums"; + import { SenderMediaMessage, - SenderMessage + SenderMessage, + _MediaCommand } from "../../cast/sdk/types"; import LoadingIndicator from "../LoadingIndicator.svelte"; @@ -37,6 +41,42 @@ /** Current media status (if available) */ $: mediaStatus = device.mediaStatus; + export let lastMenuShownDeviceId: string; + $: if (lastMenuShownDeviceId === device.id) { + void device.mediaStatus; + updateMediaMenus(); + browser.menus.refresh(); + } + + const languageNames = new Intl.DisplayNames( + [browser.i18n.getUILanguage()], + { type: "language" } + ); + + // Subtitle/caption tracks + $: textTracks = mediaStatus?.media?.tracks + ?.filter(track => track.type === TrackType.TEXT) + .map(track => { + /** + * If track has no name, but does have a language, get a + * display name for the language. + */ + if (!track.name && track.language) { + try { + const displayName = languageNames.of(track.language); + if (displayName) { + track.name = displayName; + } + // eslint-disable-next-line no-empty + } catch (err) {} + } + + return track; + }); + $: activeTextTrackId = mediaStatus?.activeTrackIds?.find(trackId => + textTracks?.find(track => track.trackId === trackId) + ); + /** Whether media controls are shown. */ let isExpanded = false; $: if (!device.mediaStatus) { @@ -79,14 +119,252 @@ }); } + let receiverElement: HTMLLIElement; + function isTarget( + info?: browser.menus._OnShownInfo | browser.menus.OnClickData + ) { + // Only handle menu events on this page + if (info?.pageUrl !== window.location.href) return false; + + if (!info.targetElementId) return false; + const targetElement = browser.menus.getTargetElement( + info.targetElementId + ); + if (!targetElement) return false; + + return ( + targetElement === receiverElement || + receiverElement.contains(targetElement) + ); + } + + // Map of menu IDs to track IDs + const captionSubmenus = new Map(); + + function onMenuShown(info: browser.menus._OnShownInfo) { + if (!isTarget(info)) { + return; + } + + lastMenuShownDeviceId = device.id; + + browser.menus.update(menuIds.POPUP_CAST, { + visible: true, + title: _("popupCastMenuTitle", device.friendlyName), + enabled: + // Not already connecting to a receiver + !isConnecting && + // Selected media type available + isMediaTypeAvailable + }); + + browser.menus.update(menuIds.POPUP_STOP, { + visible: !!application && !application.isIdleScreen, + title: application?.displayName + ? _("popupStopMenuTitle", [ + application.displayName, + device.friendlyName + ]) + : "" + }); + + updateMediaMenus(); + browser.menus.refresh(); + } + + function handleMediaPlayPause() { + switch (mediaStatus?.playerState) { + case PlayerState.PLAYING: + sendMediaMessage({ type: "PAUSE" }); + break; + case PlayerState.PAUSED: + sendMediaMessage({ type: "PLAY" }); + break; + } + } + function handleMediaSkipPrevious() { + sendMediaMessage({ + type: "QUEUE_UPDATE", + jump: -1 + }); + } + function handleMediaSkipNext() { + sendMediaMessage({ + type: "QUEUE_UPDATE", + jump: 1 + }); + } + function handleMediaTrackChange(activeTrackIds: number[]) { + sendMediaMessage({ + type: "EDIT_TRACKS_INFO", + activeTrackIds: activeTrackIds + }); + } + function handleVolumeChange(volume: Partial) { + sendReceiverMessage({ + type: "SET_VOLUME", + volume + }); + } + + function onMenuClicked(info: browser.menus.OnClickData) { + if (!isTarget(info)) return; + + switch (info.menuItemId) { + case menuIds.POPUP_MEDIA_PLAY_PAUSE: + handleMediaPlayPause(); + break; + case menuIds.POPUP_MEDIA_MUTE: + if ( + !device.status?.volume.muted && + device.status?.volume.level === 0 + ) { + handleVolumeChange({ level: 1 }); + } else { + handleVolumeChange({ muted: !device.status?.volume.muted }); + } + break; + case menuIds.POPUP_MEDIA_SKIP_PREVIOUS: + handleMediaSkipPrevious(); + break; + case menuIds.POPUP_MEDIA_SKIP_NEXT: + handleMediaSkipNext(); + break; + } + + // Handle caption submenu items + if (info.parentMenuItemId === menuIds.POPUP_MEDIA_CC) { + // Filter and append active track IDs array + if (!mediaStatus?.activeTrackIds) return; + const activeTrackIds = mediaStatus.activeTrackIds.filter( + activeTrackId => activeTrackId !== activeTextTrackId + ); + + const trackId = captionSubmenus.get(info.menuItemId); + if (trackId) { + activeTrackIds.push(trackId); + } + + handleMediaTrackChange(activeTrackIds); + } + } + + function onContextMenu() { + browser.menus.overrideContext({ showDefaults: false }); + } + + /** Updates media menu items from media status. */ + function updateMediaMenus() { + // Clear caption submenu for re-build + if (captionSubmenus.size) { + for (const menuId of captionSubmenus.keys()) { + browser.menus.remove(menuId); + } + captionSubmenus.clear(); + } + + // Hide all media menu items if no media status + if (!mediaStatus) { + for (const menuId of menuIds.mediaMenuIds) { + browser.menus.update(menuId, { visible: false }); + } + return; + } + + browser.menus.update(menuIds.POPUP_MEDIA_SEPARATOR, { + visible: true + }); + + // Play/pause menu item + if (mediaStatus.supportedMediaCommands & _MediaCommand.PAUSE) { + browser.menus.update(menuIds.POPUP_MEDIA_PLAY_PAUSE, { + visible: true, + title: + mediaStatus.playerState === PlayerState.PLAYING || + mediaStatus.playerState === PlayerState.BUFFERING + ? _("popupMediaPause") + : _("popupMediaPlay"), + enabled: + mediaStatus.playerState === PlayerState.PLAYING || + mediaStatus.playerState === PlayerState.PAUSED + }); + } else { + browser.menus.update(menuIds.POPUP_MEDIA_PLAY_PAUSE, { + visible: false + }); + } + + // Mute/unmute menu item + if (device.status?.volume) { + const volume = device.status.volume; + + browser.menus.update(menuIds.POPUP_MEDIA_MUTE, { + visible: true, + title: _("popupMediaMute"), + checked: volume.muted || volume.level === 0, + enabled: "muted" in volume + }); + } else { + browser.menus.update(menuIds.POPUP_MEDIA_MUTE, { + visible: false + }); + } + + browser.menus.update(menuIds.POPUP_MEDIA_SKIP_PREVIOUS, { + visible: !!( + mediaStatus.supportedMediaCommands & _MediaCommand.QUEUE_PREV + ) + }); + browser.menus.update(menuIds.POPUP_MEDIA_SKIP_NEXT, { + visible: !!( + mediaStatus.supportedMediaCommands & _MediaCommand.QUEUE_NEXT + ) + }); + + // Build captions submenu from text tracks + if ( + textTracks?.length && + mediaStatus.supportedMediaCommands & _MediaCommand.EDIT_TRACKS + ) { + browser.menus.update(menuIds.POPUP_MEDIA_CC, { visible: true }); + browser.menus.update(menuIds.POPUP_MEDIA_CC_OFF, { + visible: true, + checked: activeTextTrackId === undefined + }); + + for (const track of textTracks) { + const menuId = browser.menus.create({ + title: track.name ?? track.trackId.toString(), + parentId: menuIds.POPUP_MEDIA_CC, + type: "radio", + checked: track.trackId === activeTextTrackId + }); + + captionSubmenus.set(menuId, track.trackId); + } + } else { + browser.menus.update(menuIds.POPUP_MEDIA_CC, { + visible: false + }); + } + } + onMount(() => { sendMediaMessage({ type: "GET_STATUS" }); + + browser.menus.onShown.addListener(onMenuShown); + browser.menus.onClicked.addListener(onMenuClicked); + + return () => { + browser.menus.onShown.removeListener(onMenuShown); + browser.menus.onClicked.removeListener(onMenuClicked); + }; }); -
  • +
  • { - sendMediaMessage({ - type: "QUEUE_UPDATE", - jump: -1 - }); - }} - on:next={() => { - sendMediaMessage({ - type: "QUEUE_UPDATE", - jump: 1 - }); - }} + {textTracks} + on:togglePlayback={() => handleMediaPlayPause()} + on:previous={() => handleMediaSkipPrevious()} + on:next={() => handleMediaSkipNext()} on:seek={ev => { sendMediaMessage({ type: "SEEK", currentTime: ev.detail.position }); }} - on:trackChanged={ev => { - sendMediaMessage({ - type: "EDIT_TRACKS_INFO", - activeTrackIds: ev.detail.activeTrackIds - }); - }} - on:volumeChanged={ev => { - sendReceiverMessage({ - type: "SET_VOLUME", - volume: ev.detail - }); - }} + on:trackChanged={ev => + handleMediaTrackChange(ev.detail.activeTrackIds)} + on:volumeChanged={ev => handleVolumeChange(ev.detail)} /> {/if} diff --git a/ext/src/ui/popup/ReceiverMedia.svelte b/ext/src/ui/popup/ReceiverMedia.svelte index f849e82..a7defd8 100644 --- a/ext/src/ui/popup/ReceiverMedia.svelte +++ b/ext/src/ui/popup/ReceiverMedia.svelte @@ -8,9 +8,9 @@ import { MetadataType, PlayerState, - StreamType, - TrackType + StreamType } from "../../cast/sdk/media/enums"; + import type { Track } from "../../cast/sdk/media/classes"; import { getEstimatedTime } from "../../cast/utils"; const _ = browser.i18n.getMessage; @@ -26,6 +26,7 @@ export let status: MediaStatus; export let device: ReceiverDevice; + export let textTracks: Track[] = []; $: isPlayingOrPaused = status.playerState === PlayerState.PLAYING || @@ -72,32 +73,6 @@ } } - const languageNames = new Intl.DisplayNames( - [browser.i18n.getUILanguage()], - { type: "language" } - ); - - // Subtitle/caption tracks - $: textTracks = status?.media?.tracks - ?.filter(track => track.type === TrackType.TEXT) - .map(track => { - /** - * If track has no name, but does have a language, get a - * display name for the language. - */ - if (!track.name && track.language) { - try { - const displayName = languageNames.of(track.language); - if (displayName) { - track.name = displayName; - } - // eslint-disable-next-line no-empty - } catch (err) {} - } - - return track; - }); - // Keep track of update times for currentTime estimations let lastUpdateTime = 0; let currentTime = getEstimatedMediaTime(); @@ -258,7 +233,7 @@ class="media__cc-button ghost" class:media__cc-button--off={activeTextTrackId === undefined} - title={_("popupMediaSubtitlesClosedCaptions")} + title={_("popupMediaSubtitlesCaptions")} value={activeTextTrackId} on:change={ev => { if (!status.activeTrackIds) return; @@ -276,7 +251,7 @@ }} > {#each textTracks as track}