Add media controls (#229)

This commit is contained in:
Matt Hensman
2022-08-24 02:17:35 +01:00
committed by GitHub
parent cbc039a355
commit ac46802431
37 changed files with 1694 additions and 432 deletions

View File

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

View File

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

View File

@@ -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"

View File

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

View File

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

View File

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

View 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>

View 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>

View File

@@ -0,0 +1,4 @@
import { writable } from "svelte/store";
import { ReceiverDevice } from "../../types";
export default writable<ReceiverDevice[]>([]);

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -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%;
}