Merge branch 'update/connection-status' into update/undo-removals

This commit is contained in:
Jaroslav Polakovič 2021-03-17 16:01:43 +01:00
Родитель db4d4cbdb0 e3283e7aba
Коммит ab6262cfd4
17 изменённых файлов: 386 добавлений и 152 удалений

Просмотреть файл

@ -13,6 +13,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<div id="offline-banner">No internet connection</div>
<header class="_with-video">
<div class="container">
<h1><a data-use-router href="/">KINO</a></h1>
@ -24,9 +25,6 @@
<a data-use-router href="/">Home</a>
<a data-use-router href="/downloads">Manage Downloads</a>
<a data-use-router href="/settings">Settings</a>
<span>
Offline <toggle-button id="offline-content-only"></toggle-button> Online
</span>
</div>
</div>
</header>
@ -40,8 +38,6 @@
</div>
</footer>
<div id="connection-status"></div>
<script type="module" src="/dist/js/index.js"></script>
</body>

Просмотреть файл

@ -50,6 +50,27 @@ table {
box-sizing: border-box;
}
/**
* Animations.
*/
@keyframes shake {
10%, 90% {
transform: translateX(-50.5%);
}
20%, 80% {
transform: translateX(-49%);
}
30%, 50%, 70% {
transform: translateX(-48%);
}
40%, 60% {
transform: translateX(-52%);
}
}
/* App Styles */
html, body {
margin: 0;
@ -69,22 +90,30 @@ body {
.tip {
padding: 2rem;
}
#connection-status {
#offline-banner {
position: fixed;
bottom: 0;
right: 0;
width: auto;
opacity: 0;
display: none;
bottom: 1em;
left: 50%;
transform: translateX(-50%);
z-index: 100;
font: normal 0.8em sans-serif;
padding: 0.5em 1em;
border-radius: 4px;
overflow: hidden;
color: #fff;
padding: 0.5em;
text-transform: uppercase;
border-top-left-radius: 5px;
}
.online {
background: #2eb872;
}
.offline {
background: #fa4659;
text-transform: uppercase;
transition: opacity 200ms ease-in-out;
white-space: pre;
}
[data-connection="offline"] #offline-banner {
display: inline-block;
opacity: 1;
}
#offline-banner.alert {
animation: shake 0.6s cubic-bezier(.36,.07,.19,.97) both;
}
h3 {
@ -216,8 +245,8 @@ header .menu span {
border-radius: 2rem;
padding: 0.5rem 1rem;
}
header .menu toggle-button {
padding: 0 1rem;
header .menu offline-toggle-button {
padding-right: 1rem;
}
header .container {
padding: 2rem 0;

Просмотреть файл

@ -2,9 +2,8 @@
* Router, Connection utils.
*/
import Router from './js/modules/Router.module';
import updateOnlineStatus from './js/utils/updateOnlineStatus';
import initializeGlobalToggle from './js/utils/initializeGlobalToggle';
import VideoDownloaderRegistry from './js/modules/VideoDownloaderRegistry.module';
import ConnectionStatus from './js/modules/ConnectionStatus.module';
/**
* Web Components implementation.
@ -14,6 +13,7 @@ import VideoCardComponent from './js/components/VideoCard';
import VideoDownloaderComponent from './js/components/VideoDownloader';
import VideoGrid from './js/components/VideoGrid';
import ToggleButton from './js/components/ToggleButton';
import OfflineToggleButton from './js/components/OfflineToggleButton';
import ProgressRing from './js/components/ProgressRing';
/**
@ -25,6 +25,12 @@ import CategoryPage from './js/pages/Category';
import DownloadsPage from './js/pages/Downloads';
import SettingsPage from './js/pages/Settings';
/**
* Settings
*/
import { loadSetting } from './js/utils/settings';
import { SETTING_KEY_TOGGLE_OFFLINE } from './js/constants';
/**
* Custom Elements definition.
*/
@ -33,19 +39,55 @@ customElements.define('video-card', VideoCardComponent);
customElements.define('video-downloader', VideoDownloaderComponent);
customElements.define('video-grid', VideoGrid);
customElements.define('toggle-button', ToggleButton);
customElements.define('offline-toggle-button', OfflineToggleButton);
customElements.define('progress-ring', ProgressRing);
/**
* Tracks the connection status of the application and broadcasts
* when the connections status changes.
*/
const offlineForced = loadSetting(SETTING_KEY_TOGGLE_OFFLINE) || false;
const connectionStatus = new ConnectionStatus(offlineForced);
const offlineBanner = document.querySelector('#offline-banner');
/**
* Allow the page styling to respond to the global connection status.
*
* If an alert is emitted, slide in the "Not connected" message to inform
* the user the action they attempted can't be performed right now.
*/
connectionStatus.subscribe(
({ navigatorStatus, alert }) => {
document.body.dataset.connection = navigatorStatus;
if (alert && navigatorStatus === 'offline') {
offlineBanner.classList.add('alert');
setTimeout(() => offlineBanner.classList.remove('alert'), 600);
}
},
);
/**
* Initialize a registry holding instances of the `VideoDownload` web components.
*
* This is to allow us to share these instances between pages.
*/
const videoDownloaderRegistry = new VideoDownloaderRegistry();
const videoDownloaderRegistry = new VideoDownloaderRegistry({ connectionStatus });
/**
* Bind the offline toggle(s) to the `ConnectionStatus` instance.
*/
[...document.querySelectorAll('offline-toggle-button')].forEach(
(button) => button.assignConnectionStatus(connectionStatus),
);
/**
* Router setup.
*/
const router = new Router({ videoDownloaderRegistry });
const router = new Router({
videoDownloaderRegistry,
connectionStatus,
});
router.route('/', HomePage);
router.route('/settings', SettingsPage);
router.route('/downloads', DownloadsPage);
@ -60,12 +102,3 @@ if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('sw.js');
});
}
/**
* Connection status.
*/
window.addEventListener('online', updateOnlineStatus);
window.addEventListener('offline', updateOnlineStatus);
updateOnlineStatus();
initializeGlobalToggle();

Просмотреть файл

@ -0,0 +1,79 @@
import { loadSetting, saveSetting, removeSetting } from '../utils/settings';
import { SETTING_KEY_TOGGLE_OFFLINE } from '../constants';
import ToggleButton from './ToggleButton';
/**
* Respond to button interaction.
*
* @param {boolean} forceOffline Force the offline state.
* @param {ConnectionStatus} connectionStatus ConnectionStatus instance.
*/
const buttonInteractionHandler = (forceOffline, connectionStatus) => {
if (forceOffline) {
saveSetting(SETTING_KEY_TOGGLE_OFFLINE, true);
connectionStatus.forceOffline();
} else if (connectionStatus.getStatusDetail().navigatorStatus === 'offline') {
/**
* If we want to leave offline mode, but we're not online, let's
* prevent the action and broadcast a "Not connected" alert instead.
*/
connectionStatus.alert();
} else {
removeSetting(SETTING_KEY_TOGGLE_OFFLINE);
connectionStatus.unforceOffline();
}
};
/**
* Respond to connection status changes.
*
* @param {HTMLElement} button The toggle button element.
* @param {ConnectionStatus} connectionStatus ConnectionStatus instance.
*/
const networkChangeHandler = (button, connectionStatus) => {
const offlineModeEnabled = loadSetting(SETTING_KEY_TOGGLE_OFFLINE);
if (offlineModeEnabled) {
button.checked = true;
} else {
button.checked = (connectionStatus.status === 'offline');
}
};
/**
* The offline toggle button is a special case of a toggle button
* in a sense that it accepts and uses a `connectionStatus`
* instance to help drive its logic.
*/
export default class OfflineToggleButton extends ToggleButton {
constructor() {
super();
this.initialized = false;
}
/**
* @param {ConnectionStatus} connectionStatus ConnectionStatus instance.
*/
assignConnectionStatus(connectionStatus) {
if (this.initialized) return;
/**
* On button interaction, we want to persist the state if it's
* based on user gesture.
*/
this.$checkbox.addEventListener('change', (e) => {
const forceOffline = (e.target.checked === true);
buttonInteractionHandler(forceOffline, connectionStatus);
});
/**
* Respond to network changes.
*/
connectionStatus.subscribe(
() => networkChangeHandler(this.$checkbox, connectionStatus),
);
this.initialized = true;
}
}

Просмотреть файл

@ -60,6 +60,7 @@ export default class ToggleButton extends HTMLElement {
this.shadowRoot.appendChild(template.content.cloneNode(true));
this.$checkbox = this._root.querySelector('input');
this.$checkbox.addEventListener('change', (e) => {
this.checked = e.target.checked;
this.dispatchEvent(
new CustomEvent('change', { detail: { value: e.target.checked } }),
);
@ -71,7 +72,7 @@ export default class ToggleButton extends HTMLElement {
}
get checked() {
return this.getAttribute('checked');
return this.getAttribute('checked') === 'true';
}
set checked(value) {

Просмотреть файл

@ -1,5 +1,3 @@
import { loadSetting } from '../utils/settings';
const style = `
<style>
:host {
@ -54,36 +52,32 @@ const style = `
}
</style>`;
export default class extends HTMLElement {
/**
* When the connection status changes, enable or disable the card
* with respect to the download state.
*
* @param {ConnectionStatus} connectionStatus ConnectionStatus instance.
* @param {VideoDownloader} downloader `VideoDownloader` instance.
*/
function connectionStatusChangeHandler(connectionStatus, downloader) {
if (connectionStatus.status === 'offline' && downloader.state !== 'done') {
this.classList.add('disabled');
} else {
this.classList.remove('disabled');
}
}
export default class VideoCard extends HTMLElement {
constructor() {
super();
this._root = this.attachShadow({ mode: 'open' });
window.addEventListener('online', this.updateOnlineStatus.bind(this));
window.addEventListener('online-mock', this.updateOnlineStatus.bind(this, { mock: true }));
window.addEventListener('offline', this.updateOnlineStatus.bind(this));
window.addEventListener('offline-mock', this.updateOnlineStatus.bind(this, { mock: false }));
}
updateOnlineStatus(opts = {}) {
const isOnline = opts.mock !== undefined ? opts.mock : navigator.onLine;
const offlineContentOnly = loadSetting('offline-content-only');
const isDownloaded = opts.downloader && (opts.downloader.state === 'done');
if (((!isOnline || offlineContentOnly) && !isDownloaded)) {
this.classList.add('disabled');
} else {
this.classList.remove('disabled');
}
}
attachDownloader(downloader) {
downloader.onStatusUpdate = this.updateOnlineStatus.bind(this, { downloader });
this._root.querySelector('.downloader').appendChild(downloader);
this.updateOnlineStatus();
}
render(videoData, navigate) {
this.navigate = navigate;
render({
videoData,
connectionStatus,
downloader,
}) {
const templateElement = document.createElement('template');
let posterImage = videoData.thumbnail;
@ -108,5 +102,15 @@ export default class extends HTMLElement {
const ui = templateElement.content.cloneNode(true);
this._root.appendChild(ui);
this._root.querySelector('.downloader').appendChild(downloader);
const boundHandler = connectionStatusChangeHandler.bind(this, connectionStatus, downloader);
/**
* Whenever connection status or downloader state changes,
* maybe disable / enable the card.
*/
connectionStatus.subscribe(boundHandler);
downloader.subscribe(boundHandler);
}
}

Просмотреть файл

@ -155,12 +155,14 @@ export default class extends HTMLElement {
return ['state', 'progress', 'downloading', 'willremove'];
}
constructor() {
constructor({ connectionStatus }) {
super();
// Attach Shadow DOM.
this.internal = {};
this.internal.root = this.attachShadow({ mode: 'open' });
this.internal = {
connectionStatus,
changeCallbacks: [],
root: this.attachShadow({ mode: 'open' }),
};
}
/**
@ -180,10 +182,21 @@ export default class extends HTMLElement {
}
set state(state) {
const oldState = this.state;
this.setAttribute('state', state);
if (this.onStatusUpdate) {
this.onStatusUpdate(state);
}
this.internal.changeCallbacks.forEach(
(callback) => callback(oldState, state),
);
}
/**
* Subscribe to state changes.
*
* @param {Function} callback Callback function to run when the component's state changes.
*/
subscribe(callback) {
this.internal.changeCallbacks.push(callback);
}
get downloading() {

Просмотреть файл

@ -97,3 +97,8 @@ export const DEFAULT_AUDIO_PRIORITIES = [
* These are all the types the Streamer has support for.
*/
export const ALL_STREAM_TYPES = ['audio', 'video'];
/**
* Settings key names.
*/
export const SETTING_KEY_TOGGLE_OFFLINE = 'toggle-offline';

Просмотреть файл

@ -0,0 +1,99 @@
export default class ConnectionStatus {
constructor(offlineForced = false) {
this.internal = {
status: navigator.onLine ? 'online' : 'offline',
offlineForced,
changeCallbacks: [],
};
window.addEventListener('online', () => {
this.internal.status = 'online';
this.broadcast();
});
window.addEventListener('offline', () => {
this.internal.status = 'offline';
this.broadcast();
});
}
/**
* Returns the connection status, optionally overriden by the
* offline status being forced.
*
* @returns {string} Connection status.
*/
get status() {
return this.internal.offlineForced ? 'offline' : this.internal.status;
}
/**
* Toggle forced offline mode on.
*/
forceOffline() {
this.internal.offlineForced = true;
this.broadcast();
}
/**
* Toggle forced offline mode off.
*/
unforceOffline() {
this.internal.offlineForced = false;
this.broadcast();
}
/**
* Returns detailed information about the current status.
*
* @returns {object} Detailed information about the status.
*/
getStatusDetail() {
return {
status: this.status,
navigatorStatus: this.internal.status,
forcedOffline: this.internal.offlineForced,
};
}
/**
* Subscribe to connection status changes.
*
* @param {Function} callback Callback function to run when connection status changes.
*/
subscribe(callback) {
const detail = this.getStatusDetail();
this.internal.changeCallbacks.push(callback);
callback(detail);
}
/**
* Broadcast the status to all subscribers and emits a global event
* signalling the change.
*
* @param {object} detail Detail object to be broadcasted.
*/
broadcast(detail = null) {
if (!detail) detail = this.getStatusDetail();
this.internal.changeCallbacks.forEach(
(callback) => callback(detail),
);
window.dispatchEvent(
new CustomEvent('connection-change', { detail }),
);
}
/**
* Broadcast the detail information with alert flag set to true in order
* to indicate user action that couldn't be finished because the client is offline.
*/
alert() {
const detail = this.getStatusDetail();
detail.alert = true;
this.broadcast(detail);
}
}

Просмотреть файл

@ -8,8 +8,9 @@ import VideoDownloader from '../components/VideoDownloader';
* This helps maintain the component's state even across page loads.
*/
export default class VideoDownloaderRegistry {
constructor() {
constructor({ connectionStatus }) {
this.instances = new Map();
this.connectionStatus = connectionStatus;
}
/**
@ -20,7 +21,7 @@ export default class VideoDownloaderRegistry {
* @returns {VideoDownloader} Instantiated VideoDownloader.
*/
create(videoId) {
this.instances.set(videoId, new VideoDownloader());
this.instances.set(videoId, new VideoDownloader({ connectionStatus: this.connectionStatus }));
return this.instances.get(videoId);
}

Просмотреть файл

@ -1,5 +1,5 @@
import getIDBConnection from '../modules/IDBConnection.module';
import { SW_CACHE_NAME } from '../constants';
import getDownloaderElement from '../utils/getDownloaderElement.module';
/**
* @param {RouterContext} routerContext Context object passed by the Router.
@ -9,6 +9,7 @@ export default async (routerContext) => {
mainContent,
apiData,
navigate,
connectionStatus,
videoDownloaderRegistry,
} = routerContext;
mainContent.innerHTML = `
@ -53,14 +54,15 @@ export default async (routerContext) => {
allMeta.forEach((meta) => {
const videoData = apiData.find((vd) => vd.id === meta.videoId);
const card = document.createElement('video-card');
let downloader = videoDownloaderRegistry.get(videoData.id);
if (!downloader) {
downloader = videoDownloaderRegistry.create(videoData.id);
downloader.init(videoData, SW_CACHE_NAME);
}
downloader.setAttribute('expanded', 'false');
card.render(videoData, navigate);
card.attachDownloader(downloader);
const downloader = getDownloaderElement(videoDownloaderRegistry, videoData);
card.render({
videoData,
navigate,
connectionStatus,
downloader,
});
grid.appendChild(card);
});

Просмотреть файл

@ -1,14 +1,11 @@
import { saveSetting, loadSetting } from '../utils/settings';
const onChange = (key) => ({ detail }) => {
saveSetting(key, detail.value);
};
/**
* @param {RouterContext} routerContext Context object passed by the Router.
*/
export default (routerContext) => {
const { mainContent } = routerContext;
const {
mainContent,
connectionStatus,
} = routerContext;
mainContent.innerHTML = `
<div class="page-title">
<h2>Settings</h2>
@ -16,7 +13,7 @@ export default (routerContext) => {
</div>
<div class="settings">
<div class="option">
<toggle-button id="offline-content-only"></toggle-button>
<offline-toggle-button></offline-toggle-button>
<div>
<h4>Show offline content only</h4>
<p>When enabled, you will only be shown content that is available offline.</p>
@ -31,14 +28,8 @@ export default (routerContext) => {
<!-- </div>-->
</div>
`;
const toggleButtonOffline = mainContent.querySelector('toggle-button#offline-content-only');
toggleButtonOffline.addEventListener('change', onChange('offline-content-only'));
// TODO: Listen for global toggle change and sync this local setting?
// Should we enable and gray-out that option while offline?
// +auto enable when going offline on this page?
const isOffline = !navigator.onLine;
if (loadSetting('offline-content-only') || isOffline) {
toggleButtonOffline.checked = true;
}
mainContent
.querySelector('offline-toggle-button')
.assignConnectionStatus(connectionStatus);
};

Просмотреть файл

@ -1,4 +1,4 @@
import { SW_CACHE_NAME } from '../constants';
import getDownloaderElement from './getDownloaderElement.module';
/**
* @param {RouterContext} routerContext Context passed through by Router.
@ -10,6 +10,7 @@ function appendVideoToGallery(routerContext, localContext) {
apiData,
navigate,
mainContent,
connectionStatus,
} = routerContext;
const category = localContext.category || '';
@ -23,18 +24,15 @@ function appendVideoToGallery(routerContext, localContext) {
apiData.forEach((videoData) => {
const card = document.createElement('video-card');
const downloader = getDownloaderElement(videoDownloaderRegistry, videoData);
let downloader = videoDownloaderRegistry.get(videoData.id);
if (!downloader) {
downloader = videoDownloaderRegistry.create(videoData.id);
downloader.init(videoData, SW_CACHE_NAME);
}
downloader.setAttribute('expanded', 'false');
card.render({
videoData,
navigate,
connectionStatus,
downloader,
});
const player = document.createElement('video-player');
card.render(videoData, navigate);
card.attachDownloader(downloader);
player.render(videoData);
videoGallery.appendChild(card);
});

Просмотреть файл

@ -0,0 +1,20 @@
import { SW_CACHE_NAME } from '../constants';
/**
* Returns a `VideoDownloader` object for a given video ID.
*
* @param {VideoDownloaderRegistry} videoDownloaderRegistry Registry.
* @param {object} videoData Video data.
*
* @returns {VideoDownloader} `VideoDownloader` instance.
*/
export default (videoDownloaderRegistry, videoData) => {
let downloader = videoDownloaderRegistry.get(videoData.id);
if (!downloader) {
downloader = videoDownloaderRegistry.create(videoData.id);
downloader.init(videoData, SW_CACHE_NAME);
}
downloader.setAttribute('expanded', 'false');
return downloader;
};

Просмотреть файл

@ -1,32 +0,0 @@
import { loadSetting, saveSetting } from './settings';
const onChange = (key) => ({ detail }) => {
saveSetting(key, !detail.value);
window.dispatchEvent(new CustomEvent(`${!detail.value ? 'offline' : 'online'}-mock`));
};
/**
* Update online status for header toggle
*/
function updateOnlineStatus() {
const toggleButtonOffline = document.querySelector('header toggle-button#offline-content-only');
toggleButtonOffline.checked = navigator.onLine;
}
/**
* Initialize the offline/online toggle from the header.
*/
export default function initializeGlobalToggle() {
const toggleButtonOffline = document.querySelector('header toggle-button#offline-content-only');
toggleButtonOffline.addEventListener('change', onChange('offline-content-only'));
window.addEventListener('online', updateOnlineStatus);
window.addEventListener('offline', updateOnlineStatus);
// Should we enable and gray-out that option while offline?
// +auto enable when going offline on this page?
const isOnline = navigator.onLine;
if (!loadSetting('offline-content-only') || isOnline) {
toggleButtonOffline.checked = true;
}
}

Просмотреть файл

@ -13,3 +13,12 @@ export function saveSetting(key, value) {
export function loadSetting(key) {
return JSON.parse(localStorage.getItem(key));
}
/**
* Removes a settings entry.
*
* @param {string} key Setting key.
*/
export function removeSetting(key) {
localStorage.removeItem(key);
}

Просмотреть файл

@ -1,14 +0,0 @@
import { saveSetting } from './settings';
/**
* Update online status helper.
*/
export default function updateOnlineStatus() {
const status = document.getElementById('connection-status');
const condition = navigator.onLine ? 'online' : 'offline';
status.className = condition;
status.innerHTML = condition;
// If we want to sync the setting with actual connection state
saveSetting('offline-content-only', condition === 'offline');
}