Add media controls (#229)
@@ -3,11 +3,14 @@
|
||||
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<style>
|
||||
path {
|
||||
fill: rgba(12, 12, 13, .8);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
path {
|
||||
fill: rgba(249, 249, 250, .8);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<path fill="rgba(12, 12, 13, .8)" d="M8 12a1 1 0 0 1-.707-.293l-5-5a1 1 0 0 1 1.414-1.414L8 9.586l4.293-4.293a1 1 0 0 1 1.414 1.414l-5 5A1 1 0 0 1 8 12z"></path>
|
||||
</svg>
|
||||
<path d="M8 12a1 1 0 0 1-.707-.293l-5-5a1 1 0 0 1 1.414-1.414L8 9.586l4.293-4.293a1 1 0 0 1 1.414 1.414l-5 5A1 1 0 0 1 8 12z"></path>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 629 B After Width: | Height: | Size: 666 B |
@@ -3,11 +3,14 @@
|
||||
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<style>
|
||||
path {
|
||||
fill: rgba(12, 12, 13, .8);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
path {
|
||||
fill: rgba(249, 249, 250, .8);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<path fill="rgba(12, 12, 13, .8)" d="M13 11a1 1 0 0 1-.707-.293L8 6.414l-4.293 4.293a1 1 0 0 1-1.414-1.414l5-5a1 1 0 0 1 1.414 0l5 5A1 1 0 0 1 13 11z"></path>
|
||||
<path d="M13 11a1 1 0 0 1-.707-.293L8 6.414l-4.293 4.293a1 1 0 0 1-1.414-1.414l5-5a1 1 0 0 1 1.414 0l5 5A1 1 0 0 1 13 11z"></path>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 625 B After Width: | Height: | Size: 663 B |
@@ -19,7 +19,7 @@
|
||||
let editingInput: HTMLInputElement;
|
||||
let editingValue: string;
|
||||
|
||||
let expandedItemIndices = new Set();
|
||||
let expandedItemIndices = new Set<number>();
|
||||
|
||||
let knownAppToAdd: Nullable<KnownApp> = null;
|
||||
$: filteredKnownApps = Object.values(knownApps).filter(app => {
|
||||
@@ -217,7 +217,7 @@
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="assets/{isItemExpanded
|
||||
src="../assets/{isItemExpanded
|
||||
? 'photon_arrowhead_up.svg'
|
||||
: 'photon_arrowhead_down.svg'}"
|
||||
alt="icon, arrow down"
|
||||
|
||||
@@ -1,15 +1,3 @@
|
||||
:root {
|
||||
--border-color: var(--grey-90-a20);
|
||||
--secondary-color: rgb(125, 125, 125);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--border-color: var(--grey-10-a20);
|
||||
--secondary-color: var(--grey-10-a60);
|
||||
}
|
||||
}
|
||||
|
||||
#root {
|
||||
padding: 20px 10px;
|
||||
}
|
||||
@@ -35,19 +23,6 @@ input:placeholder-shown {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
button.ghost {
|
||||
width: 24px !important;
|
||||
height: 24px !important;
|
||||
padding: initial;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
button.ghost:not(:hover) {
|
||||
background-color: initial;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -30,6 +30,9 @@
|
||||
--field-box-shadow-error: 0 0 0 1px var(--red-60),
|
||||
0 0 0 4px var(--red-60-a30);
|
||||
|
||||
--border-color: var(--grey-90-a20);
|
||||
--secondary-color: rgb(125, 125, 125);
|
||||
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
@@ -46,9 +49,16 @@
|
||||
--field-placeholder-color: var(--grey-30);
|
||||
--field-border-color: var(--grey-10-a20);
|
||||
--field-border-color-hover: var(--grey-10-a30);
|
||||
|
||||
--border-color: var(--grey-10-a20);
|
||||
--secondary-color: var(--grey-10-a60);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
@@ -62,34 +72,40 @@ select {
|
||||
background-color: var(--button-background);
|
||||
color: var(--button-color);
|
||||
}
|
||||
button:not(:disabled):hover,
|
||||
select:not(:disabled):hover {
|
||||
button:not(:disabled):hover {
|
||||
background-color: var(--button-background-hover);
|
||||
}
|
||||
button:not(:disabled):active,
|
||||
select:not(:disabled):hover:active {
|
||||
button:not(:disabled):active {
|
||||
background-color: var(--button-background-active);
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
textarea,
|
||||
select {
|
||||
background-color: var(--field-background);
|
||||
border: 1px solid var(--field-border-color);
|
||||
color: var(--field-color);
|
||||
}
|
||||
|
||||
input:hover,
|
||||
textarea:hover {
|
||||
textarea:hover,
|
||||
select:hover {
|
||||
border-color: var(--field-border-color-hover);
|
||||
}
|
||||
|
||||
:-moz-any(button, input, textarea, select):focus {
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
textarea:focus-visible,
|
||||
select:focus-visible {
|
||||
border-color: var(--focus-border-color) !important;
|
||||
box-shadow: var(--focus-box-shadow);
|
||||
outline: initial;
|
||||
}
|
||||
|
||||
:-moz-any(button, input, textarea, select):focus::-moz-focus-inner {
|
||||
button::-moz-focus-inner,
|
||||
input::-moz-focus-inner,
|
||||
textarea::-moz-focus-inner,
|
||||
select::-moz-focus-inner {
|
||||
border: initial;
|
||||
}
|
||||
|
||||
@@ -130,6 +146,22 @@ button:default:hover:active {
|
||||
background-color: var(--button-background-primary-active);
|
||||
}
|
||||
|
||||
.ghost {
|
||||
align-items: center;
|
||||
background-position: center center;
|
||||
background-repeat: no-repeat;
|
||||
display: flex;
|
||||
height: 24px !important;
|
||||
justify-content: center;
|
||||
padding: initial;
|
||||
width: 24px !important;
|
||||
}
|
||||
|
||||
.ghost:not(:hover),
|
||||
.ghost:disabled {
|
||||
background-color: initial;
|
||||
}
|
||||
|
||||
.select-wrapper {
|
||||
--arrow-width: 16px;
|
||||
position: relative;
|
||||
@@ -137,12 +169,16 @@ button:default:hover:active {
|
||||
}
|
||||
.select-wrapper::after {
|
||||
align-items: center;
|
||||
content: "▼";
|
||||
opacity: 0.5;
|
||||
background-image: url("assets/photon_arrowhead_down.svg");
|
||||
background-position: center center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 80%;
|
||||
content: "";
|
||||
display: flex;
|
||||
height: 100%;
|
||||
margin-right: 4px;
|
||||
justify-content: center;
|
||||
margin-right: 4px;
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<script lang="ts">
|
||||
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<string> = null;
|
||||
|
||||
/** Extension options */
|
||||
let opts: Nullable<Options> = null;
|
||||
@@ -65,6 +62,8 @@
|
||||
let browserWindow: Nullable<browser.windows.Window> = 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 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="media-type-select">
|
||||
<div class="media-type-select__label-cast">
|
||||
{_("popupMediaSelectCastLabel")}
|
||||
</div>
|
||||
<div
|
||||
class="select-wrapper"
|
||||
class:select-wrapper--disabled={availableMediaTypes ===
|
||||
ReceiverSelectorMediaType.None}
|
||||
>
|
||||
<select
|
||||
class="media-type-select__dropdown"
|
||||
bind:value={mediaType}
|
||||
disabled={availableMediaTypes === ReceiverSelectorMediaType.None}
|
||||
>
|
||||
<option
|
||||
value={ReceiverSelectorMediaType.App}
|
||||
disabled={!isAppMediaTypeAvailable}
|
||||
>
|
||||
{knownApp?.name ?? _("popupMediaTypeApp")}
|
||||
</option>
|
||||
|
||||
{#if opts?.mirroringEnabled}
|
||||
{#if availableMediaTypes !== ReceiverSelectorMediaType.None}
|
||||
<div class="media-type-select">
|
||||
<div class="media-type-select__label-cast">
|
||||
{_("popupMediaSelectCastLabel")}
|
||||
</div>
|
||||
<div class="select-wrapper">
|
||||
<select class="media-type-select__dropdown" bind:value={mediaType}>
|
||||
<option
|
||||
value={ReceiverSelectorMediaType.Tab}
|
||||
disabled={!(
|
||||
availableMediaTypes & ReceiverSelectorMediaType.Tab
|
||||
)}
|
||||
value={ReceiverSelectorMediaType.App}
|
||||
disabled={!isAppMediaTypeAvailable}
|
||||
>
|
||||
{_("popupMediaTypeTab")}
|
||||
{knownApp?.name ?? _("popupMediaTypeApp")}
|
||||
</option>
|
||||
<option
|
||||
value={ReceiverSelectorMediaType.Screen}
|
||||
disabled={!(
|
||||
availableMediaTypes & ReceiverSelectorMediaType.Screen
|
||||
)}
|
||||
>
|
||||
{_("popupMediaTypeScreen")}
|
||||
</option>
|
||||
{/if}
|
||||
</select>
|
||||
</div>
|
||||
<div class="media-type-select__label-to">
|
||||
{_("popupMediaSelectToLabel")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="receivers">
|
||||
{#if !receiverDevices.length}
|
||||
<div class="receivers__not-found">
|
||||
{#if opts?.mirroringEnabled}
|
||||
<option
|
||||
value={ReceiverSelectorMediaType.Tab}
|
||||
disabled={!(
|
||||
availableMediaTypes & ReceiverSelectorMediaType.Tab
|
||||
)}
|
||||
>
|
||||
{_("popupMediaTypeTab")}
|
||||
</option>
|
||||
<option
|
||||
value={ReceiverSelectorMediaType.Screen}
|
||||
disabled={!(
|
||||
availableMediaTypes &
|
||||
ReceiverSelectorMediaType.Screen
|
||||
)}
|
||||
>
|
||||
{_("popupMediaTypeScreen")}
|
||||
</option>
|
||||
{/if}
|
||||
</select>
|
||||
</div>
|
||||
<div class="media-type-select__label-to">
|
||||
{_("popupMediaSelectToLabel")}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<ul class="receiver-list">
|
||||
{#if !$deviceStore.length}
|
||||
<div class="receiver-list__not-found">
|
||||
{_("popupNoReceiversFound")}
|
||||
</div>
|
||||
{:else}
|
||||
{#each receiverDevices as device}
|
||||
{@const application = device.status?.applications?.[0]}
|
||||
{@const isDeviceConnecting =
|
||||
isConnecting && connectingId === device.id}
|
||||
|
||||
<li class="receiver">
|
||||
<div class="receiver__name">
|
||||
{device.friendlyName}
|
||||
</div>
|
||||
<div class="receiver__address">
|
||||
{application && !application.isIdleScreen
|
||||
? application.statusText
|
||||
: `${device.host}:${device.port}`}
|
||||
</div>
|
||||
<button
|
||||
class="button receiver__connect"
|
||||
on:click={() => onReceiverCast(device)}
|
||||
disabled={isConnecting ||
|
||||
isDeviceConnecting ||
|
||||
!isMediaTypeAvailable}
|
||||
>
|
||||
{#if isDeviceConnecting}
|
||||
{_("popupCastingButtonTitle", "")}<LoadingIndicator />
|
||||
{:else}
|
||||
{_("popupCastButtonTitle")}
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{#each $deviceStore as device}
|
||||
<Receiver
|
||||
{port}
|
||||
{device}
|
||||
{isMediaTypeAvailable}
|
||||
isAnyConnecting={isConnecting}
|
||||
on:cast={ev => onReceiverCast(ev.detail.device)}
|
||||
on:stop={ev => onReceiverStop(ev.detail.device)}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
</ul>
|
||||
|
||||
179
ext/src/ui/popup/Receiver.svelte
Normal file
@@ -0,0 +1,179 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, onMount } from "svelte";
|
||||
|
||||
import { PlayerState } from "../../cast/sdk/media/enums";
|
||||
import { SenderMediaMessage, SenderMessage } from "../../cast/sdk/types";
|
||||
import { ReceiverDevice } from "../../types";
|
||||
|
||||
import { Port } from "../../messaging";
|
||||
|
||||
import LoadingIndicator from "../LoadingIndicator.svelte";
|
||||
import ReceiverMedia from "./ReceiverMedia.svelte";
|
||||
|
||||
const _ = browser.i18n.getMessage;
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
cast: { device: ReceiverDevice };
|
||||
stop: { device: ReceiverDevice };
|
||||
}>();
|
||||
|
||||
export let port: Nullable<Port>;
|
||||
|
||||
/** Whether there are sessions being established for any receiver. */
|
||||
export let isAnyConnecting: boolean;
|
||||
/** Whether the selected media type is available for this receiver. */
|
||||
export let isMediaTypeAvailable: boolean;
|
||||
|
||||
/** Receiver device to display. */
|
||||
export let device: ReceiverDevice;
|
||||
|
||||
/** Current receiver application (if available) */
|
||||
$: application = device.status?.applications?.[0];
|
||||
/** Current media status (if available) */
|
||||
$: mediaStatus = device.mediaStatus;
|
||||
|
||||
let isExpanded = false;
|
||||
let isConnecting = false;
|
||||
|
||||
function sendReceiverMessage(
|
||||
partialMessage: DistributiveOmit<SenderMessage, "requestId">
|
||||
) {
|
||||
const message: SenderMessage = {
|
||||
...partialMessage,
|
||||
requestId: 0
|
||||
};
|
||||
|
||||
port?.postMessage({
|
||||
subject: "receiverSelector:receiverMessage",
|
||||
data: { deviceId: device.id, message }
|
||||
});
|
||||
}
|
||||
function sendMediaMessage(
|
||||
partialMessage: DistributiveOmit<
|
||||
SenderMediaMessage,
|
||||
"requestId" | "mediaSessionId"
|
||||
>
|
||||
) {
|
||||
if (!device.mediaStatus) return;
|
||||
|
||||
const message: SenderMediaMessage = {
|
||||
...(partialMessage as any),
|
||||
requestId: 0,
|
||||
mediaSessionId: device.mediaStatus.mediaSessionId
|
||||
};
|
||||
|
||||
port?.postMessage({
|
||||
subject: "receiverSelector:mediaMessage",
|
||||
data: { deviceId: device.id, message }
|
||||
});
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
sendMediaMessage({
|
||||
type: "GET_STATUS"
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<li class="receiver">
|
||||
<div class="receiver__details">
|
||||
<div class="receiver__name">
|
||||
{device.friendlyName}
|
||||
</div>
|
||||
{#if application && !application.isIdleScreen}
|
||||
<div class="receiver__status">
|
||||
<span class="receiver__app-name">
|
||||
{application.displayName}
|
||||
</span>
|
||||
{#if application.statusText !== application.displayName}
|
||||
· {application.statusText}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if application && !application.isIdleScreen}
|
||||
<button
|
||||
class="receiver__stop-button"
|
||||
on:click={() => dispatch("stop", { device })}
|
||||
>
|
||||
{_("popupStopButtonTitle")}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="receiver__cast-button"
|
||||
disabled={isConnecting || isAnyConnecting || !isMediaTypeAvailable}
|
||||
on:click={() => {
|
||||
isConnecting = true;
|
||||
dispatch("cast", { device });
|
||||
}}
|
||||
>
|
||||
{#if isConnecting}
|
||||
{_("popupCastingButtonTitle", "")}<LoadingIndicator />
|
||||
{:else}
|
||||
{_("popupCastButtonTitle")}
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="receiver__expand-button ghost"
|
||||
class:receiver__expand-button--expanded={isExpanded}
|
||||
title={_("popupShowDetailsTitle")}
|
||||
disabled={!mediaStatus}
|
||||
on:click={() => {
|
||||
isExpanded = !isExpanded;
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if isExpanded}
|
||||
<div class="receiver__expanded">
|
||||
{#if mediaStatus}
|
||||
<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
|
||||
});
|
||||
}}
|
||||
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
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
367
ext/src/ui/popup/ReceiverMedia.svelte
Normal file
@@ -0,0 +1,367 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, onMount } from "svelte";
|
||||
|
||||
import { ReceiverDevice } from "../../types";
|
||||
import { MediaStatus, _MediaCommand } from "../../cast/sdk/types";
|
||||
import { Image, Volume } from "../../cast/sdk/classes";
|
||||
import {
|
||||
MetadataType,
|
||||
PlayerState,
|
||||
StreamType,
|
||||
TrackType
|
||||
} from "../../cast/sdk/media/enums";
|
||||
|
||||
const _ = browser.i18n.getMessage;
|
||||
|
||||
import deviceStore from "./deviceStore";
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
togglePlayback: void;
|
||||
seek: { position: number };
|
||||
previous: void;
|
||||
next: void;
|
||||
trackChanged: { activeTrackIds: number[] };
|
||||
volumeChanged: Partial<Volume>;
|
||||
}>();
|
||||
|
||||
export let status: MediaStatus;
|
||||
export let device: ReceiverDevice;
|
||||
|
||||
$: isPlayingOrPaused =
|
||||
status.playerState === PlayerState.PLAYING ||
|
||||
status.playerState === PlayerState.PAUSED;
|
||||
|
||||
let mediaTitle: Optional<string>;
|
||||
let mediaSubtitle: Optional<string>;
|
||||
let mediaImage: Optional<Image>;
|
||||
|
||||
// Choose subset of metadata depending on metadata type
|
||||
$: {
|
||||
const metadata = status?.media?.metadata;
|
||||
|
||||
mediaTitle = metadata?.title;
|
||||
mediaImage = metadata?.images?.[0];
|
||||
mediaSubtitle = undefined;
|
||||
|
||||
if (metadata) {
|
||||
switch (metadata.metadataType) {
|
||||
case MetadataType.AUDIOBOOK_CHAPTER:
|
||||
if (metadata.bookTitle) {
|
||||
metadata.title = metadata.bookTitle;
|
||||
}
|
||||
metadata.subtitle = metadata.chapterTitle;
|
||||
break;
|
||||
case MetadataType.MUSIC_TRACK:
|
||||
mediaSubtitle = metadata.artist;
|
||||
break;
|
||||
case MetadataType.TV_SHOW:
|
||||
if (metadata.seriesTitle) {
|
||||
mediaTitle = metadata.seriesTitle;
|
||||
mediaSubtitle = metadata.title;
|
||||
}
|
||||
break;
|
||||
|
||||
case MetadataType.MOVIE:
|
||||
case MetadataType.GENERIC:
|
||||
mediaSubtitle = metadata.subtitle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 = getEstimatedTime();
|
||||
|
||||
deviceStore.subscribe(devices => {
|
||||
const newDevice = devices.find(newDevice => newDevice.id === device.id);
|
||||
if (newDevice?.mediaStatus?.currentTime) {
|
||||
lastUpdateTime = Date.now();
|
||||
currentTime = newDevice.mediaStatus.currentTime;
|
||||
}
|
||||
});
|
||||
|
||||
// Update estimated time every second
|
||||
onMount(() => {
|
||||
const intervalId = window.setInterval(() => {
|
||||
if (currentTime !== getEstimatedTime()) {
|
||||
currentTime = getEstimatedTime();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Estimates the current playback position based on the last status
|
||||
* update.
|
||||
*/
|
||||
function getEstimatedTime() {
|
||||
if (!status.currentTime) return 0;
|
||||
|
||||
if (status.playerState === PlayerState.PLAYING && lastUpdateTime) {
|
||||
let estimatedTime =
|
||||
status.currentTime + (Date.now() - lastUpdateTime) / 1000;
|
||||
|
||||
if (estimatedTime < 0) {
|
||||
estimatedTime = 0;
|
||||
} else if (
|
||||
status.media?.duration &&
|
||||
estimatedTime > status.media.duration
|
||||
) {
|
||||
estimatedTime = status.media.duration;
|
||||
}
|
||||
|
||||
return estimatedTime;
|
||||
}
|
||||
|
||||
return status.currentTime;
|
||||
}
|
||||
|
||||
/** Formats seconds into HH:MM:SS */
|
||||
function formatTime(seconds: number) {
|
||||
const date = new Date(seconds * 1000);
|
||||
const hours = date.getUTCHours();
|
||||
|
||||
let ret = "";
|
||||
if (hours) ret += `${hours}:`;
|
||||
ret += `${date.getUTCMinutes()}:`;
|
||||
ret += `${date.getUTCSeconds()}`.padStart(2, "0");
|
||||
return ret;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="media" style:--media-image="url({mediaImage?.url})">
|
||||
{#if mediaTitle}
|
||||
<div class="media__metadata">
|
||||
<div class="media__title" title={mediaTitle}>
|
||||
{mediaTitle}
|
||||
</div>
|
||||
{#if mediaSubtitle}
|
||||
<div class="media__subtitle">
|
||||
{mediaSubtitle}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="media__controls">
|
||||
<!-- Seek bar -->
|
||||
{#if status.media && status.media?.duration}
|
||||
<div class="media__seek">
|
||||
{#if status.media?.streamType === StreamType.LIVE}
|
||||
<span class="media__live">
|
||||
{_("popupMediaLive")}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="media__current-time">
|
||||
{formatTime(currentTime)}
|
||||
</span>
|
||||
{#if status.supportedMediaCommands & _MediaCommand.SEEK}
|
||||
<input
|
||||
type="range"
|
||||
class="slider media__seek-bar"
|
||||
class:slider--indeterminate={status.playerState ===
|
||||
PlayerState.BUFFERING}
|
||||
aria-label={_("popupMediaSeek")}
|
||||
max={status.media.duration ?? currentTime}
|
||||
value={currentTime}
|
||||
on:change={ev =>
|
||||
dispatch("seek", {
|
||||
position: ev.currentTarget.valueAsNumber
|
||||
})}
|
||||
/>
|
||||
{:else}
|
||||
<progress
|
||||
class="slider media__seek-bar"
|
||||
class:slider--indeterminate={status.playerState ===
|
||||
PlayerState.BUFFERING}
|
||||
max={status.media.duration ?? currentTime}
|
||||
value={currentTime}
|
||||
/>
|
||||
{/if}
|
||||
{#if status.media.duration}
|
||||
<span class="media__remaining-time">
|
||||
-{formatTime(status.media?.duration - currentTime)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="media__buttons">
|
||||
{#if status.supportedMediaCommands & _MediaCommand.QUEUE_PREV}
|
||||
<button
|
||||
class="media__previous-button ghost"
|
||||
title={_("popupMediaSkipPrevious")}
|
||||
on:click={() => dispatch("previous")}
|
||||
/>
|
||||
{/if}
|
||||
{#if status.supportedMediaCommands & _MediaCommand.SEEK}
|
||||
<button
|
||||
class="media__backward-button ghost"
|
||||
title={_("popupMediaSeekBackward")}
|
||||
disabled={!isPlayingOrPaused}
|
||||
on:click={() =>
|
||||
dispatch("seek", { position: currentTime - 5 })}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if status.supportedMediaCommands & _MediaCommand.PAUSE}
|
||||
<button
|
||||
class={`ghost ${
|
||||
status.playerState === PlayerState.PLAYING ||
|
||||
status.playerState === PlayerState.BUFFERING
|
||||
? "media__pause-button"
|
||||
: "media__play-button"
|
||||
}`}
|
||||
title={isPlayingOrPaused &&
|
||||
status.playerState === PlayerState.PLAYING
|
||||
? _("popupMediaPause")
|
||||
: _("popupMediaPlay")}
|
||||
disabled={!isPlayingOrPaused}
|
||||
on:click={() => dispatch("togglePlayback")}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if status.supportedMediaCommands & _MediaCommand.SEEK}
|
||||
<button
|
||||
class="media__forward-button ghost"
|
||||
disabled={!isPlayingOrPaused}
|
||||
title={_("popupMediaSeekForward")}
|
||||
on:click={() =>
|
||||
dispatch("seek", { position: currentTime + 5 })}
|
||||
/>
|
||||
{/if}
|
||||
{#if status.supportedMediaCommands & _MediaCommand.QUEUE_NEXT}
|
||||
<button
|
||||
class="media__next-button ghost"
|
||||
title={_("popupMediaSkipNext")}
|
||||
on:click={() => dispatch("next")}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if textTracks?.length && status.supportedMediaCommands & _MediaCommand.EDIT_TRACKS}
|
||||
{@const activeTextTrackId = status.activeTrackIds?.find(
|
||||
trackId =>
|
||||
textTracks?.find(track => track.trackId === trackId)
|
||||
)}
|
||||
|
||||
<select
|
||||
class="media__cc-button ghost"
|
||||
class:media__cc-button--off={activeTextTrackId ===
|
||||
undefined}
|
||||
title={_("popupMediaSubtitlesClosedCaptions")}
|
||||
value={activeTextTrackId}
|
||||
on:change={ev => {
|
||||
if (!status.activeTrackIds) return;
|
||||
|
||||
let activeTrackIds = status.activeTrackIds.filter(
|
||||
trackId => trackId !== activeTextTrackId
|
||||
);
|
||||
|
||||
const trackId = parseInt(ev.currentTarget.value);
|
||||
if (!Number.isNaN(trackId)) {
|
||||
activeTrackIds.push(trackId);
|
||||
}
|
||||
|
||||
dispatch("trackChanged", { activeTrackIds });
|
||||
}}
|
||||
>
|
||||
<option value={undefined}>
|
||||
{_("popupMediaSubtitlesClosedCaptionsOff")}
|
||||
</option>
|
||||
{#each textTracks as track}
|
||||
<option value={track.trackId}>
|
||||
{track.name ?? track.trackId}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
|
||||
<!-- Current time for unseekable live streams since seek bar
|
||||
is unnecessary -->
|
||||
{#if isPlayingOrPaused && !status.media?.duration}
|
||||
{#if status.media?.streamType === StreamType.LIVE}
|
||||
<span class="media__live">
|
||||
{_("popupMediaLive")}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="media__current-time">
|
||||
{formatTime(currentTime)}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if device.status?.volume}
|
||||
{@const volume = device.status?.volume}
|
||||
{@const isMuted = volume.muted || volume.level === 0}
|
||||
|
||||
<div class="media__volume">
|
||||
<button
|
||||
class="media__mute-button ghost"
|
||||
class:media__mute-button--muted={isMuted}
|
||||
disabled={!("muted" in volume)}
|
||||
title={isMuted
|
||||
? _("popupMediaUnmute")
|
||||
: _("popupMediaMute")}
|
||||
on:click={() => {
|
||||
/**
|
||||
* If not muted and volume is at 0, max out
|
||||
* volume instead of flipping mute value.
|
||||
*/
|
||||
if (!volume.muted && volume.level === 0) {
|
||||
dispatch("volumeChanged", {
|
||||
level: 1
|
||||
});
|
||||
} else {
|
||||
dispatch("volumeChanged", {
|
||||
muted: !volume.muted
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
class="slider media__volume-slider"
|
||||
aria-label={_("popupMediaVolume")}
|
||||
disabled={!("level" in volume)}
|
||||
step="0.05"
|
||||
max={1}
|
||||
value={volume.muted ? 0 : volume.level}
|
||||
on:change={ev => {
|
||||
dispatch("volumeChanged", {
|
||||
level: ev.currentTarget.valueAsNumber
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
4
ext/src/ui/popup/deviceStore.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { writable } from "svelte/store";
|
||||
import { ReceiverDevice } from "../../types";
|
||||
|
||||
export default writable<ReceiverDevice[]>([]);
|
||||
19
ext/src/ui/popup/icons/audio-muted.svg
Normal file
@@ -0,0 +1,19 @@
|
||||
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
||||
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
|
||||
<style>
|
||||
path {
|
||||
fill: rgba(12, 12, 13, .8);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
path {
|
||||
fill: rgba(249, 249, 250, .8);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<path d="m11 4.149 0 4.181 1.775 1.775c.3-.641.475-1.35.475-2.105a4.981 4.981 0 0 0-1.818-3.851l-.432 0z" />
|
||||
<path d="M2.067 1.183a.626.626 0 0 0-.885.885L4.115 5 2 5a2 2 0 0 0-2 2l0 2a2 2 0 0 0 2 2l2.117 0 3.128 3.65C7.848 15.353 9 14.927 9 14l0-4.116 3.317 3.317c-.273.232-.56.45-.873.636a.624.624 0 0 0-.218.856.621.621 0 0 0 .856.219 7.58 7.58 0 0 0 1.122-.823l.729.729a.626.626 0 0 0 .884-.886L2.067 1.183z" />
|
||||
<path d="M9 2c0-.926-1.152-1.352-1.755-.649L5.757 3.087 9 6.33 9 2z" />
|
||||
<path d="M11.341 2.169a6.767 6.767 0 0 1 3.409 5.864 6.732 6.732 0 0 1-.83 3.217l.912.912A7.992 7.992 0 0 0 16 8.033a8.018 8.018 0 0 0-4.04-6.95.625.625 0 0 0-.619 1.086z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
18
ext/src/ui/popup/icons/audio-none.svg
Normal file
@@ -0,0 +1,18 @@
|
||||
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
||||
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18px" height="18px" viewBox="0 0 18 18">
|
||||
<style>
|
||||
path {
|
||||
fill: rgba(12, 12, 13, .8);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
path {
|
||||
fill: rgba(249, 249, 250, .8);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<path d="M14.901,3.571l-4.412,3.422V1.919L6.286,5.46H4.869c-1.298,0-2.36,1.062-2.36,2.36v2.36
|
||||
c0,1.062,0.708,1.888,1.652,2.242l-2.242,1.77l1.18,1.416L16.081,4.987L14.901,3.571z M10.489,16.081V11.36l-2.669,2.36
|
||||
L10.489,16.081z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 788 B |
18
ext/src/ui/popup/icons/audio.svg
Normal file
@@ -0,0 +1,18 @@
|
||||
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
||||
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
|
||||
<style>
|
||||
path {
|
||||
fill: rgba(12, 12, 13, .8);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
path {
|
||||
fill: rgba(249, 249, 250, .8);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<path d="M7.245 1.35 4.117 5 2 5a2 2 0 0 0-2 2l0 2a2 2 0 0 0 2 2l2.117 0 3.128 3.65C7.848 15.353 9 14.927 9 14L9 2c0-.927-1.152-1.353-1.755-.65z" />
|
||||
<path d="M11.764 15a.623.623 0 0 1-.32-1.162 6.783 6.783 0 0 0 3.306-5.805 6.767 6.767 0 0 0-3.409-5.864.624.624 0 1 1 .619-1.085A8.015 8.015 0 0 1 16 8.033a8.038 8.038 0 0 1-3.918 6.879c-.1.06-.21.088-.318.088z" />
|
||||
<path d="M11.434 11.85A4.982 4.982 0 0 0 13.25 8a4.982 4.982 0 0 0-1.819-3.852l-.431 0 0 7.702.434 0z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1011 B |
17
ext/src/ui/popup/icons/backward.svg
Normal file
@@ -0,0 +1,17 @@
|
||||
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
||||
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<style>
|
||||
path {
|
||||
fill: rgba(12, 12, 13, .8);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
path {
|
||||
fill: rgba(249, 249, 250, .8);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<path d="M15.996 3.995c0-.55-.386-.753-.857-.46l-6.284 3.93c-.473.295-.47.776 0 1.07l6.287 3.93c.474.296.858.08.858-.46v-8.01Z" />
|
||||
<path d="M7.495 3.995c0-.55-.386-.753-.857-.46L.354 7.465c-.473.295-.47.776 0 1.07l6.287 3.93c.474.296.858.08.858-.46v-8.01Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 797 B |
23
ext/src/ui/popup/icons/cc-off.svg
Normal file
@@ -0,0 +1,23 @@
|
||||
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
||||
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18px" height="18px" viewBox="0 0 18 18">
|
||||
<style>
|
||||
path {
|
||||
fill: rgba(12, 12, 13, .8);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
path {
|
||||
fill: rgba(249, 249, 250, .8);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<path fill-rule="evenodd" d="M16.531,16.107H5.267l1.982-2H15c0.6,0,1-0.4,1-1V5.274
|
||||
l1.946-1.964C17.963,3.399,18,3.483,18,3.576v11.031C18,15.407,17.331,16.107,16.531,16.107z M14.016,8.506h-1.218l1.005-1.014
|
||||
C13.913,7.789,13.984,8.128,14.016,8.506z M11.786,12.361c-0.828,0-1.476-0.326-1.913-0.902l1.09-1.101
|
||||
c0.136,0.323,0.374,0.541,0.796,0.541c0.514,0,0.695-0.44,0.756-1.014h1.535C13.908,11.43,13.071,12.361,11.786,12.361z
|
||||
M1.496,16.106C0.697,16.104,0,15.406,0,14.607V3.576c0-0.8,0.7-1.5,1.5-1.5h12.846L16.299,0l1.316,1.283L2.615,17.13L1.496,16.106
|
||||
z M3,4.107c-0.6,0-1,0.4-1,1v8c0,0.6,0.4,1,1,1h0.029l2.031-2.16c-0.757-0.503-1.191-1.457-1.191-2.744
|
||||
c0-1.936,1.069-3.14,2.428-3.14c1.357,0,2.136,0.76,2.361,2.059l3.777-4.016H3z M8.298,8.506H7.355
|
||||
c-0.047-0.623-0.49-1.23-0.99-1.23c-0.561,0-1.337,0.84-1.337,1.995c0,0.674,0.381,1.427,0.95,1.702L8.298,8.506z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
24
ext/src/ui/popup/icons/cc-on.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
||||
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18px" height="18px" viewBox="0 0 18 18">
|
||||
<style>
|
||||
path {
|
||||
fill: rgba(12, 12, 13, .8);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
path {
|
||||
fill: rgba(249, 249, 250, .8);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<path d="M16.531,1.984H1.5c-0.8,0-1.5,0.7-1.5,1.5v11.031c0,0.8,0.7,1.5,1.5,1.5h15.031
|
||||
c0.8,0,1.469-0.7,1.469-1.5V3.484C18,2.684,17.331,1.984,16.531,1.984z
|
||||
M16,13.016c0,0.6-0.4,1-1,1H3c-0.6,0-1-0.4-1-1v-8c0-0.6,0.4-1,1-1h12c0.6,0,1,0.4,1,1V13.016z
|
||||
M6.426,10.807c-0.811,0-0.96-0.789-0.96-1.628c0-1.155,0.338-1.745,0.899-1.745c0.5,0,0.818,0.357,0.866,0.98
|
||||
h1.484C8.585,6.877,7.785,5.972,6.297,5.972c-1.359,0-2.428,1.205-2.428,3.14c0,1.944,0.974,3.157,2.583,3.157
|
||||
c1.285,0,2.153-0.93,2.295-2.476H7.244C7.183,10.367,6.94,10.807,6.426,10.807z
|
||||
M11.759,10.807c-0.811,0-0.96-0.789-0.96-1.628c0-1.155,0.338-1.745,0.899-1.745c0.5,0,0.756,0.357,0.803,0.98h1.515
|
||||
c-0.129-1.537-0.898-2.443-2.385-2.443c-1.359,0-2.396,1.205-2.396,3.14c0,1.944,0.943,3.157,2.552,3.157
|
||||
c1.285,0,2.122-0.93,2.264-2.476h-1.535C12.454,10.367,12.273,10.807,11.759,10.807z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
17
ext/src/ui/popup/icons/forward.svg
Normal file
@@ -0,0 +1,17 @@
|
||||
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
||||
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<style>
|
||||
path {
|
||||
fill: rgba(12, 12, 13, .8);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
path {
|
||||
fill: rgba(249, 249, 250, .8);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<path d="M.004 12.005c0 .55.386.753.857.46l6.284-3.93c.473-.295.47-.776 0-1.07L.858 3.535C.384 3.239 0 3.455 0 3.995v8.01z" />
|
||||
<path d="M8.505 12.005c0 .55.386.753.857.46l6.284-3.93c.473-.295.47-.776 0-1.07L9.36 3.535c-.474-.296-.858-.08-.858.46v8.01z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 793 B |
16
ext/src/ui/popup/icons/next.svg
Normal file
@@ -0,0 +1,16 @@
|
||||
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
||||
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<style>
|
||||
path {
|
||||
fill: rgba(12, 12, 13, .8);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
path {
|
||||
fill: rgba(249, 249, 250, .8);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<path d="M13.502 12.005c0 .55-.444.995-1 .995-.552 0-1-.456-1-.995v-8.01c0-.55.444-.995 1-.995.552 0 1 .456 1 .995zm-10.498 0c0 .55.386.753.857.46l6.284-3.93c.473-.295.47-.776 0-1.07l-6.287-3.93c-.474-.296-.858-.08-.858.46v8.01z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 766 B |
17
ext/src/ui/popup/icons/pause.svg
Normal file
@@ -0,0 +1,17 @@
|
||||
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
||||
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
|
||||
<style>
|
||||
path {
|
||||
fill: rgba(12, 12, 13, .8);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
path {
|
||||
fill: rgba(249, 249, 250, .8);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<path d="m4.5 14-1 0A1.5 1.5 0 0 1 2 12.5l0-9A1.5 1.5 0 0 1 3.5 2l1 0A1.5 1.5 0 0 1 6 3.5l0 9A1.5 1.5 0 0 1 4.5 14z" />
|
||||
<path d="m11.5 14-1 0A1.5 1.5 0 0 1 9 12.5l0-9A1.5 1.5 0 0 1 10.5 2l1 0A1.5 1.5 0 0 1 13 3.5l0 9a1.5 1.5 0 0 1-1.5 1.5z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 780 B |
16
ext/src/ui/popup/icons/play.svg
Normal file
@@ -0,0 +1,16 @@
|
||||
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
||||
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
|
||||
<style>
|
||||
path {
|
||||
fill: rgba(12, 12, 13, .8);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
path {
|
||||
fill: rgba(249, 249, 250, .8);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<path d="m2.992 13.498 0-10.996a1.5 1.5 0 0 1 2.245-1.303l9.621 5.498a1.5 1.5 0 0 1 0 2.605L5.237 14.8a1.5 1.5 0 0 1-2.245-1.302z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 666 B |
16
ext/src/ui/popup/icons/previous.svg
Normal file
@@ -0,0 +1,16 @@
|
||||
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
||||
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<style>
|
||||
path {
|
||||
fill: rgba(12, 12, 13, .8);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
path {
|
||||
fill: rgba(249, 249, 250, .8);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<path d="M3 3.995C3 3.445 3.444 3 4 3c.552 0 1 .456 1 .995v8.01c0 .55-.444.995-1 .995-.552 0-1-.456-1-.995v-8.01zm10.498 0c0-.55-.386-.753-.857-.46l-6.284 3.93c-.473.295-.47.776 0 1.07l6.287 3.93c.474.296.858.08.858-.46v-8.01z"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 769 B |
BIN
ext/src/ui/popup/icons/throbber.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
@@ -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);
|
||||
}
|
||||
|
||||
/* <input type="range"> 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;
|
||||
}
|
||||
|
||||
/* <progress> 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%;
|
||||
}
|
||||
|
||||