diff --git a/.eslintrc.json b/.eslintrc.json index cf6543f..c4253e3 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -24,6 +24,7 @@ "max-classes-per-file": "off", "prefer-promise-reject-errors": "off", "class-methods-use-this": "off", - "jsdoc/no-undefined-types": "off" + "jsdoc/no-undefined-types": "off", + "no-restricted-syntax": "off" } } diff --git a/public/index.html b/public/index.html index 94ba9e4..cc0b06c 100644 --- a/public/index.html +++ b/public/index.html @@ -13,6 +13,7 @@ +
No internet connection

KINO

@@ -24,9 +25,6 @@

KINO

Home Manage Downloads Settings - - Offline Online -
@@ -40,8 +38,6 @@

KINO

-
- diff --git a/public/styles.css b/public/styles.css index 872674a..124c738 100644 --- a/public/styles.css +++ b/public/styles.css @@ -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; + background: #fa4659; text-transform: uppercase; - border-top-left-radius: 5px; + transition: opacity 200ms ease-in-out; + white-space: pre; } -.online { - background: #2eb872; +[data-connection="offline"] #offline-banner { + display: inline-block; + opacity: 1; } -.offline { - background: #fa4659; +#offline-banner.alert { + animation: shake 0.6s cubic-bezier(.36,.07,.19,.97) both; } h3 { @@ -127,7 +156,8 @@ header { padding-bottom: 56.25%; /* 16:9 */ position: relative; } -.poster-image picture img { +.poster-image picture img, +.poster-image > img { width: 100%; height: auto; } @@ -163,7 +193,7 @@ header.with-video { height: auto; } .poster-wrapper .info { - padding: 2rem 2rem 0 2rem; + padding: 2rem 0; } .poster-wrapper .has-player .info { display: none; @@ -216,11 +246,11 @@ 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; + padding: 2rem; display: flex; justify-content: space-between; align-items: center; @@ -271,7 +301,7 @@ header .hamburger, header .close { article { background: #FFF; - padding: 4rem 2rem; + padding: 4rem 0; } article h2 { font-size: 2rem; @@ -319,8 +349,9 @@ article p { } .container { - max-width: 1200px; + max-width: calc(1200px + 4rem); margin: 0 auto; + padding: 0 2rem; } div.card { @@ -360,7 +391,7 @@ div.card { footer { background: var(--background-dark); color: var(--text-footer); - padding: 2rem; + padding: 2rem 0; font-size: 1rem; } footer a { diff --git a/src/index.js b/src/index.js index a681560..748dddf 100644 --- a/src/index.js +++ b/src/index.js @@ -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(); diff --git a/src/js/components/OfflineToggleButton.js b/src/js/components/OfflineToggleButton.js new file mode 100644 index 0000000..a628066 --- /dev/null +++ b/src/js/components/OfflineToggleButton.js @@ -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; + } +} diff --git a/src/js/components/ToggleButton.js b/src/js/components/ToggleButton.js index a0f7a13..704223a 100644 --- a/src/js/components/ToggleButton.js +++ b/src/js/components/ToggleButton.js @@ -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) { diff --git a/src/js/components/VideoCard.js b/src/js/components/VideoCard.js index c0db371..2f63d86 100644 --- a/src/js/components/VideoCard.js +++ b/src/js/components/VideoCard.js @@ -1,5 +1,3 @@ -import { loadSetting } from '../utils/settings'; - const 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, true)); - window.addEventListener('offline', this.updateOnlineStatus.bind(this)); - window.addEventListener('offline-mock', this.updateOnlineStatus.bind(this, false)); - } - - updateOnlineStatus(mock) { - const isOnline = mock !== undefined ? mock : navigator.onLine; - const offlineContentOnly = loadSetting('offline-content-only'); - const downloader = this._root.querySelector('video-downloader'); - const isDownloaded = downloader.state === 'done'; - if (((!isOnline || offlineContentOnly) && !isDownloaded)) { - this.classList.add('disabled'); - } else { - this.classList.remove('disabled'); - } - } - - attachDownloader(downloader) { - downloader.onStatusUpdate = this.updateOnlineStatus.bind(this); - 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; @@ -109,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); } } diff --git a/src/js/components/VideoDownloader.js b/src/js/components/VideoDownloader.js index ae2b847..1c2eedb 100644 --- a/src/js/components/VideoDownloader.js +++ b/src/js/components/VideoDownloader.js @@ -13,6 +13,9 @@ const style = ` min-width: 26px; min-height: 26px; } + :host button { + cursor: pointer; + } .expanded { display: none; } @@ -52,7 +55,8 @@ const style = ` display: flex; align-items: center; } - :host( [state="partial"] ) .partial { + :host( [state="partial"] ) .partial, + :host( [state="ready"][willremove="true"] ) .willremove { display: flex; position: relative; } @@ -63,7 +67,8 @@ const style = ` :host( [state="partial"][downloading="true"] ) .cancel { display: none; } - :host( [state="partial"][downloading="false"] ) .cancel { + :host( [state="partial"][downloading="false"] ) .cancel, + :host( [state="ready"][willremove="true"] ) .willremove button { display: block; position: absolute; bottom: 0; @@ -79,7 +84,8 @@ const style = ` cursor: pointer; text-transform: uppercase; } - :host( [expanded="true"][state="partial"][downloading="false"] ) .cancel { + :host( [expanded="true"][state="partial"][downloading="false"] ) .cancel, + :host( [expanded="true"][state="ready"][willremove="true"] ) .willremove button { right: 0; left: initial; bottom: initial; @@ -146,15 +152,17 @@ const style = ` export default class extends HTMLElement { static get observedAttributes() { - return ['state', 'progress', 'downloading']; + 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' }), + }; } /** @@ -174,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() { @@ -202,6 +221,14 @@ export default class extends HTMLElement { this.setAttribute('progress', clampedProgress); } + get willremove() { + return this.getAttribute('willremove') === 'true'; + } + + set willremove(willremove) { + this.setAttribute('willremove', willremove); + } + /** * Observed attributes callbacks. * @@ -370,13 +397,16 @@ export default class extends HTMLElement { render() { const templateElement = document.createElement('template'); templateElement.innerHTML = `${style} + + + + + + - - - @@ -53,14 +55,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); }); diff --git a/src/js/pages/Settings.js b/src/js/pages/Settings.js index 11d0e70..828da3a 100644 --- a/src/js/pages/Settings.js +++ b/src/js/pages/Settings.js @@ -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 = `

Settings

@@ -16,7 +13,7 @@ export default (routerContext) => {
- +

Show offline content only

When enabled, you will only be shown content that is available offline.

@@ -31,14 +28,8 @@ export default (routerContext) => {
`; - 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); }; diff --git a/src/js/utils/appendVideoToGallery.js b/src/js/utils/appendVideoToGallery.js index a834f86..984507a 100644 --- a/src/js/utils/appendVideoToGallery.js +++ b/src/js/utils/appendVideoToGallery.js @@ -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); + + card.render({ + videoData, + navigate, + connectionStatus, + downloader, + }); - let downloader = videoDownloaderRegistry.get(videoData.id); - if (!downloader) { - downloader = videoDownloaderRegistry.create(videoData.id); - downloader.init(videoData, SW_CACHE_NAME); - } - downloader.setAttribute('expanded', 'false'); - - const player = document.createElement('video-player'); - card.render(videoData, navigate); - card.attachDownloader(downloader); - player.render(videoData); videoGallery.appendChild(card); }); diff --git a/src/js/utils/getDownloaderElement.module.js b/src/js/utils/getDownloaderElement.module.js new file mode 100644 index 0000000..ee79026 --- /dev/null +++ b/src/js/utils/getDownloaderElement.module.js @@ -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; +}; diff --git a/src/js/utils/initializeGlobalToggle.js b/src/js/utils/initializeGlobalToggle.js deleted file mode 100644 index 4fc2fbc..0000000 --- a/src/js/utils/initializeGlobalToggle.js +++ /dev/null @@ -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; - } -} diff --git a/src/js/utils/settings.js b/src/js/utils/settings.js index a39f62a..cd81b80 100644 --- a/src/js/utils/settings.js +++ b/src/js/utils/settings.js @@ -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); +} diff --git a/src/js/utils/updateOnlineStatus.js b/src/js/utils/updateOnlineStatus.js deleted file mode 100644 index ae700b3..0000000 --- a/src/js/utils/updateOnlineStatus.js +++ /dev/null @@ -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'); -}