Add media context menu items to receiver popup

This commit is contained in:
hensm
2022-08-28 01:30:12 +01:00
parent 1032fb1572
commit f7b6fd7a37
6 changed files with 392 additions and 166 deletions

View File

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

View File

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

30
ext/src/menuIds.ts Normal file
View File

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

View File

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

View File

@@ -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<number | string, number>();
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<Volume>) {
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);
};
});
</script>
<li class="receiver">
<li class="receiver" bind:this={receiverElement} on:contextmenu={onContextMenu}>
<img
class="receiver__icon"
src="icons/{device.capabilities & ReceiverDeviceCapabilities.VIDEO_OUT
@@ -151,46 +429,19 @@
<ReceiverMedia
status={mediaStatus}
{device}
on:togglePlayback={() => {
switch (mediaStatus?.playerState) {
case PlayerState.PLAYING:
sendMediaMessage({ type: "PAUSE" });
break;
case PlayerState.PAUSED:
sendMediaMessage({ type: "PLAY" });
break;
}
}}
on:previous={() => {
sendMediaMessage({
type: "QUEUE_UPDATE",
jump: -1
});
}}
on:next={() => {
sendMediaMessage({
type: "QUEUE_UPDATE",
jump: 1
});
}}
{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)}
/>
</div>
{/if}

View File

@@ -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 @@
}}
>
<option value={undefined}>
{_("popupMediaSubtitlesClosedCaptionsOff")}
{_("popupMediaSubtitlesCaptionsOff")}
</option>
{#each textTracks as track}
<option value={track.trackId}>