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." "description": "Alternate action button text displayed instead of popupCastButtonTitle."
}, },
"popupCastMenuTitle": { "popupCastMenuTitle": {
"message": "Cast to \"$deviceName$\"", "message": "Cast to $deviceName$",
"description": "Menu text for cast item in context menu for receivers in receiver selector.", "description": "Menu text for cast item in context menu for receivers in receiver selector.",
"placeholders": { "placeholders": {
"deviceName": { "deviceName": {
@@ -88,7 +88,7 @@
} }
}, },
"popupStopMenuTitle": { "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.", "description": "Menu text for stop item in context menu for receivers in receiver selector.",
"placeholders": { "placeholders": {
"appName": { "appName": {
@@ -133,11 +133,11 @@
"message": "Seek forwards", "message": "Seek forwards",
"description": "Media controls seek forward button title." "description": "Media controls seek forward button title."
}, },
"popupMediaSubtitlesClosedCaptions": { "popupMediaSubtitlesCaptions": {
"message": "Subtitles/closed captions", "message": "Subtitles/captions",
"description": "Media controls subtitles/cc button title." "description": "Media controls subtitles/cc button title."
}, },
"popupMediaSubtitlesClosedCaptionsOff": { "popupMediaSubtitlesCaptionsOff": {
"message": "Off", "message": "Off",
"description": "Media controls subtitles/cc button title." "description": "Media controls subtitles/cc button title."
}, },

View File

@@ -9,6 +9,8 @@ import { ReceiverSelectorMediaType } from "../types";
import ReceiverSelector, { ReceiverSelection } from "./ReceiverSelector"; import ReceiverSelector, { ReceiverSelection } from "./ReceiverSelector";
import castManager from "./castManager"; import castManager from "./castManager";
import * as menuIds from "../menuIds";
const _ = browser.i18n.getMessage; const _ = browser.i18n.getMessage;
const URL_PATTERN_HTTP = "http://*/*"; const URL_PATTERN_HTTP = "http://*/*";
@@ -69,16 +71,51 @@ export async function initMenus() {
}); });
// Popup context menus // Popup context menus
const popupUrlPattern = `${browser.runtime.getURL("ui/popup")}/*`; const createPopupMenu = (props: browser.menus._CreateCreateProperties) =>
browser.menus.create({ browser.menus.create({
id: "popup_cast", visible: false,
title: _("popupCastButtonTitle"), documentUrlPatterns: [`${browser.runtime.getURL("ui/popup")}/*`],
documentUrlPatterns: [popupUrlPattern] ...props
});
createPopupMenu({
id: menuIds.POPUP_MEDIA_PLAY_PAUSE,
title: _("popupMediaPlay")
}); });
browser.menus.create({ createPopupMenu({
id: "popup_stop", id: menuIds.POPUP_MEDIA_MUTE,
title: _("popupStopButtonTitle"), type: "checkbox",
documentUrlPatterns: [popupUrlPattern] 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); 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 options, { Options } from "../../lib/options";
import { RemoteMatchPattern } from "../../lib/matchPattern"; import { RemoteMatchPattern } from "../../lib/matchPattern";
import { receiverMenuIds } from "../../menuIds";
import { import {
ReceiverDevice, ReceiverDevice,
ReceiverDeviceCapabilities, ReceiverDeviceCapabilities,
@@ -128,8 +130,6 @@
resizeObserver.observe(document.documentElement); resizeObserver.observe(document.documentElement);
window.addEventListener("contextmenu", onContextMenu);
browser.menus.onClicked.addListener(onMenuClicked);
browser.menus.onShown.addListener(onMenuShown); browser.menus.onShown.addListener(onMenuShown);
}); });
@@ -137,8 +137,6 @@
port?.disconnect(); port?.disconnect();
resizeObserver.disconnect(); resizeObserver.disconnect();
window.removeEventListener("contextmenu", onContextMenu);
browser.menus.onClicked.removeListener(onMenuClicked);
browser.menus.onShown.removeListener(onMenuShown); browser.menus.onShown.removeListener(onMenuShown);
}); });
@@ -247,93 +245,27 @@
} }
} }
function onContextMenu(ev: MouseEvent) { /** Device ID associated with the last receiver menu that was shown. */
if (!(ev.target instanceof Element)) return; let lastMenuShownDeviceId: string;
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];
}
}
/** Handle show events for receiver context menus. */ /** Handle show events for receiver context menus. */
function onMenuShown(info: browser.menus._OnShownInfo) { function onMenuShown(info: browser.menus._OnShownInfo) {
if (!info.targetElementId) return; // Only handle menu events on this page
const target = browser.menus.getTargetElement(info.targetElementId); if (info.pageUrl !== window.location.href) return;
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;
}
if (!info.targetElementId) return; if (!info.targetElementId) return;
const target = browser.menus.getTargetElement(info.targetElementId); const targetElement = browser.menus.getTargetElement(
if (!target) return; info.targetElementId
);
if (!targetElement) return;
const device = getDeviceFromElement(target); const receiverElement = targetElement.closest(".receiver");
if (!device) return; if (!receiverElement) {
for (const menuId of receiverMenuIds) {
browser.menus.update(menuId, { visible: false });
}
switch (info.menuItemId) { browser.menus.refresh();
case "popup_cast":
onReceiverCast(device);
break;
case "popup_stop":
onReceiverStop(device);
break;
} }
} }
@@ -434,6 +366,7 @@
ReceiverSelectorMediaType.None && ReceiverSelectorMediaType.None &&
isDeviceCompatible(device)} isDeviceCompatible(device)}
isAnyConnecting={isConnecting} isAnyConnecting={isConnecting}
bind:lastMenuShownDeviceId
on:cast={ev => onReceiverCast(ev.detail.device)} on:cast={ev => onReceiverCast(ev.detail.device)}
on:stop={ev => onReceiverStop(ev.detail.device)} on:stop={ev => onReceiverStop(ev.detail.device)}
/> />

View File

@@ -4,10 +4,14 @@
import { ReceiverDevice, ReceiverDeviceCapabilities } from "../../types"; import { ReceiverDevice, ReceiverDeviceCapabilities } from "../../types";
import type { Port } from "../../messaging"; import type { Port } from "../../messaging";
import { PlayerState } from "../../cast/sdk/media/enums"; import * as menuIds from "../../menuIds";
import type {
import type { Volume } from "../../cast/sdk/classes";
import { PlayerState, TrackType } from "../../cast/sdk/media/enums";
import {
SenderMediaMessage, SenderMediaMessage,
SenderMessage SenderMessage,
_MediaCommand
} from "../../cast/sdk/types"; } from "../../cast/sdk/types";
import LoadingIndicator from "../LoadingIndicator.svelte"; import LoadingIndicator from "../LoadingIndicator.svelte";
@@ -37,6 +41,42 @@
/** Current media status (if available) */ /** Current media status (if available) */
$: mediaStatus = device.mediaStatus; $: 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. */ /** Whether media controls are shown. */
let isExpanded = false; let isExpanded = false;
$: if (!device.mediaStatus) { $: 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(() => { onMount(() => {
sendMediaMessage({ sendMediaMessage({
type: "GET_STATUS" 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> </script>
<li class="receiver"> <li class="receiver" bind:this={receiverElement} on:contextmenu={onContextMenu}>
<img <img
class="receiver__icon" class="receiver__icon"
src="icons/{device.capabilities & ReceiverDeviceCapabilities.VIDEO_OUT src="icons/{device.capabilities & ReceiverDeviceCapabilities.VIDEO_OUT
@@ -151,46 +429,19 @@
<ReceiverMedia <ReceiverMedia
status={mediaStatus} status={mediaStatus}
{device} {device}
on:togglePlayback={() => { {textTracks}
switch (mediaStatus?.playerState) { on:togglePlayback={() => handleMediaPlayPause()}
case PlayerState.PLAYING: on:previous={() => handleMediaSkipPrevious()}
sendMediaMessage({ type: "PAUSE" }); on:next={() => handleMediaSkipNext()}
break;
case PlayerState.PAUSED:
sendMediaMessage({ type: "PLAY" });
break;
}
}}
on:previous={() => {
sendMediaMessage({
type: "QUEUE_UPDATE",
jump: -1
});
}}
on:next={() => {
sendMediaMessage({
type: "QUEUE_UPDATE",
jump: 1
});
}}
on:seek={ev => { on:seek={ev => {
sendMediaMessage({ sendMediaMessage({
type: "SEEK", type: "SEEK",
currentTime: ev.detail.position currentTime: ev.detail.position
}); });
}} }}
on:trackChanged={ev => { on:trackChanged={ev =>
sendMediaMessage({ handleMediaTrackChange(ev.detail.activeTrackIds)}
type: "EDIT_TRACKS_INFO", on:volumeChanged={ev => handleVolumeChange(ev.detail)}
activeTrackIds: ev.detail.activeTrackIds
});
}}
on:volumeChanged={ev => {
sendReceiverMessage({
type: "SET_VOLUME",
volume: ev.detail
});
}}
/> />
</div> </div>
{/if} {/if}

View File

@@ -8,9 +8,9 @@
import { import {
MetadataType, MetadataType,
PlayerState, PlayerState,
StreamType, StreamType
TrackType
} from "../../cast/sdk/media/enums"; } from "../../cast/sdk/media/enums";
import type { Track } from "../../cast/sdk/media/classes";
import { getEstimatedTime } from "../../cast/utils"; import { getEstimatedTime } from "../../cast/utils";
const _ = browser.i18n.getMessage; const _ = browser.i18n.getMessage;
@@ -26,6 +26,7 @@
export let status: MediaStatus; export let status: MediaStatus;
export let device: ReceiverDevice; export let device: ReceiverDevice;
export let textTracks: Track[] = [];
$: isPlayingOrPaused = $: isPlayingOrPaused =
status.playerState === PlayerState.PLAYING || 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 // Keep track of update times for currentTime estimations
let lastUpdateTime = 0; let lastUpdateTime = 0;
let currentTime = getEstimatedMediaTime(); let currentTime = getEstimatedMediaTime();
@@ -258,7 +233,7 @@
class="media__cc-button ghost" class="media__cc-button ghost"
class:media__cc-button--off={activeTextTrackId === class:media__cc-button--off={activeTextTrackId ===
undefined} undefined}
title={_("popupMediaSubtitlesClosedCaptions")} title={_("popupMediaSubtitlesCaptions")}
value={activeTextTrackId} value={activeTextTrackId}
on:change={ev => { on:change={ev => {
if (!status.activeTrackIds) return; if (!status.activeTrackIds) return;
@@ -276,7 +251,7 @@
}} }}
> >
<option value={undefined}> <option value={undefined}>
{_("popupMediaSubtitlesClosedCaptionsOff")} {_("popupMediaSubtitlesCaptionsOff")}
</option> </option>
{#each textTracks as track} {#each textTracks as track}
<option value={track.trackId}> <option value={track.trackId}>