* Display the playlists provided by the Mopidy-Subidy and Mopidy-WebM3U extensions in addition to the ones provided by the built-in M3U plugin. * Show artists and albums from the Mopidy-Subidy backend (served by a Subsonic server).
663 lines
15 KiB
JavaScript
Executable File
663 lines
15 KiB
JavaScript
Executable File
import { indexToArray, arrayOf } from './arrays';
|
|
import { encodeUri } from './format';
|
|
|
|
/**
|
|
* Returns a function, that, as long as it continues to be invoked, will not
|
|
* be triggered. The function will be called after it stops being called for
|
|
* N milliseconds. If `immediate` is passed, trigger the function on the
|
|
* leading edge, instead of the trailing.
|
|
* */
|
|
const debounce = function (fn, wait, immediate) {
|
|
let timeout;
|
|
return function () {
|
|
const context = this;
|
|
const
|
|
args = arguments;
|
|
|
|
const later = function () {
|
|
timeout = null;
|
|
if (!immediate) {
|
|
fn.apply(context, args);
|
|
}
|
|
};
|
|
|
|
const callNow = immediate && !timeout;
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(later, wait);
|
|
|
|
if (callNow) {
|
|
fn.apply(context, args);
|
|
}
|
|
};
|
|
};
|
|
|
|
const throttle = function (fn, delay) {
|
|
let lastCall = 0;
|
|
return function (...args) {
|
|
const now = (new Date()).getTime();
|
|
if (now - lastCall < delay) {
|
|
return;
|
|
}
|
|
lastCall = now;
|
|
return fn(...args);
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Set the app's favicon to a specific image.
|
|
*
|
|
* @param filename String
|
|
*/
|
|
const setFavicon = function (filename) {
|
|
const links = document.getElementsByClassName('favicon');
|
|
for (const link of links) {
|
|
// Construct new <links>
|
|
const new_link = document.createElement('link');
|
|
new_link.className = link.className;
|
|
new_link.rel = link.rel;
|
|
new_link.href = `assets/${filename}`;
|
|
if (link.type) {
|
|
new_link.type = link.type;
|
|
}
|
|
|
|
// Remove the old one and add the new one
|
|
document.head.removeChild(link);
|
|
document.head.appendChild(new_link);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Digest a react-router's location.search string into an array of values
|
|
*
|
|
* @param key String = the key you want from the URL
|
|
* @param string String = the locaion.search string
|
|
*/
|
|
const queryString = (key, string, compact = true) => {
|
|
const elements = string.replace('?', '').split('&');
|
|
const results = elements.reduce((accumulator, current) => {
|
|
const subElements = current.split('=');
|
|
let results = [];
|
|
|
|
// We decode the URI, but also treat "+" as a space. This is needed for backend CGI.encode that
|
|
// happens when redirecting from an OAuth failure.
|
|
if (subElements[0] === key) {
|
|
results = subElements[1].split(',').map(
|
|
(item) => decodeURIComponent(item.replace(/\+/g, '%20')),
|
|
);
|
|
}
|
|
return [...accumulator, ...results];
|
|
}, []);
|
|
|
|
if (compact && results.length === 1) return results[0];
|
|
if (compact && results.length === 0) return null;
|
|
return results;
|
|
};
|
|
|
|
const generateGuid = function (type = 'numeric', length = 12) {
|
|
// numeric
|
|
if (type == 'numeric') {
|
|
const date = new Date().valueOf().toString();
|
|
const random_number = Math.floor((Math.random() * 100)).toString();
|
|
return parseInt(date + random_number);
|
|
}
|
|
const format = 'x'.repeat(length);
|
|
return format.replace(/[xy]/g, (c) => {
|
|
const r = Math.random() * 16 | 0;
|
|
const
|
|
v = c == 'x' ? r : (r & 0x3 | 0x8);
|
|
return v.toString(length);
|
|
});
|
|
};
|
|
|
|
const getCurrentPusherConnection = function (connections, connectionid) {
|
|
function isCurrentConnection(connection) {
|
|
return connection.connectionid == newProps.pusher.connectionid;
|
|
}
|
|
|
|
const currentConnection = newProps.pusher.connections.find(isCurrentConnection);
|
|
if (!currentConnection) return false;
|
|
|
|
return currentConnection;
|
|
};
|
|
|
|
/**
|
|
* Figure out a URI's source namespace
|
|
* @param uri = string
|
|
* */
|
|
const uriSource = function (uri) {
|
|
if (!uri) return '';
|
|
|
|
const exploded = `${uri}`.split(':');
|
|
return exploded[0];
|
|
};
|
|
/**
|
|
* Identify what kind of asset a URI is (playlist, album, etc)
|
|
*
|
|
* @param uri = string
|
|
* @return string
|
|
* */
|
|
const uriType = function (uri) {
|
|
if (!uri) return '';
|
|
|
|
const exploded = `${uri}`.split(':');
|
|
const is_playlist_uri = exploded.length >= 1 && exploded[1] === 'playlist'
|
|
|
|
// The following covers m3u, webm3u, subidy:playlist URI schemes.
|
|
if (exploded[0].endsWith('m3u') || is_playlist_uri) {
|
|
return 'playlist';
|
|
}
|
|
|
|
if (exploded[0] === 'youtube') {
|
|
const youtubeParts = exploded[1].split('/');
|
|
if (youtubeParts[0] === 'video') {
|
|
return 'track';
|
|
}
|
|
return youtubeParts[0];
|
|
}
|
|
|
|
if (exploded[0] === 'iris') {
|
|
return exploded[1];
|
|
}
|
|
|
|
if (exploded[0] === 'dleyna') {
|
|
return 'track';
|
|
}
|
|
|
|
if (exploded[0] === 'tunein') {
|
|
switch (exploded[1]) {
|
|
case 'station':
|
|
return 'album';
|
|
default:
|
|
return exploded[1];
|
|
}
|
|
}
|
|
|
|
switch (exploded[1]) {
|
|
case 'library':
|
|
return exploded[2];
|
|
case 'track':
|
|
case 'artist':
|
|
case 'album':
|
|
case 'playlist':
|
|
case 'genre':
|
|
return exploded[1];
|
|
case 'user':
|
|
if (exploded.length > 3 && exploded[3] === 'playlist') {
|
|
return 'playlist';
|
|
}
|
|
return exploded[1];
|
|
default:
|
|
return exploded[1];
|
|
}
|
|
};
|
|
|
|
const sourceIcon = function (uri, source = null) {
|
|
if (uri) source = uriSource(uri);
|
|
switch (source) {
|
|
case 'local':
|
|
case 'file':
|
|
case 'm3u':
|
|
case 'webm3u':
|
|
case 'subidy':
|
|
return 'folder';
|
|
|
|
case 'gmusic':
|
|
return 'google';
|
|
|
|
case 'podcast':
|
|
case 'podcast+file':
|
|
case 'podcast+http':
|
|
case 'podcast+https':
|
|
case 'podcast+itunes':
|
|
return 'podcast';
|
|
|
|
case 'tunein':
|
|
case 'somafm':
|
|
case 'dirble':
|
|
return 'cloud';
|
|
|
|
case 'spotify':
|
|
case 'soundcloud':
|
|
case 'lastfm':
|
|
case 'tidal':
|
|
case 'jellyfin':
|
|
return source;
|
|
|
|
case 'youtube':
|
|
case 'yt':
|
|
case 'ytmusic':
|
|
return 'play-circle';
|
|
|
|
default:
|
|
return 'cloud';
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Get an element from a URI
|
|
* @param element = string, the element we wish to extract
|
|
* @param uri = string
|
|
* */
|
|
const getFromUri = (element, uri) => {
|
|
if (!uri) return null;
|
|
const exploded = `${uri}`.split(':');
|
|
|
|
switch (element) {
|
|
case 'mbid':
|
|
const index = exploded.indexOf('mbid');
|
|
if (index > -1) return exploded[index + 1];
|
|
break;
|
|
|
|
case 'artistid':
|
|
if (exploded[1] == 'artist') {
|
|
return exploded[2];
|
|
}
|
|
break;
|
|
|
|
case 'albumid':
|
|
if (exploded[1] == 'album') {
|
|
return exploded[2];
|
|
}
|
|
break;
|
|
|
|
case 'playlistid':
|
|
if (exploded[1] == 'playlist') {
|
|
return exploded[2];
|
|
}
|
|
if (exploded[1] == 'user' && exploded[3] == 'playlist') {
|
|
return exploded[4];
|
|
}
|
|
break;
|
|
|
|
case 'playlistowner':
|
|
if (exploded[1] == 'user' && exploded[3] == 'playlist') {
|
|
return exploded[2];
|
|
}
|
|
break;
|
|
|
|
case 'trackid':
|
|
if (exploded[1] == 'track') {
|
|
return exploded[2];
|
|
}
|
|
if (exploded[0] === 'dleyna') {
|
|
return uri.replace('dleyna:', '');
|
|
}
|
|
break;
|
|
|
|
case 'userid':
|
|
if (exploded[1] == 'user') {
|
|
return exploded[2];
|
|
}
|
|
break;
|
|
|
|
case 'genreid':
|
|
if (exploded[1] == 'genre') {
|
|
return exploded[2];
|
|
}
|
|
break;
|
|
|
|
case 'categoryid':
|
|
if (exploded[1] == 'category') {
|
|
return exploded[2];
|
|
}
|
|
break;
|
|
|
|
case 'seeds':
|
|
if (exploded[1] == 'discover') {
|
|
return exploded[2];
|
|
} if (exploded[1] === 'radio') {
|
|
const seeds = exploded[2].split(',');
|
|
return seeds.map((seed) => seed.replace(/_/gi, ':'));
|
|
}
|
|
break;
|
|
|
|
case 'searchtype':
|
|
if (exploded[1] == 'search') {
|
|
return exploded[2];
|
|
}
|
|
break;
|
|
|
|
case 'searchterm':
|
|
if (exploded[1] == 'search') {
|
|
return exploded[3];
|
|
}
|
|
break;
|
|
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Build a link to an asset. Using the URI type we can ascertain where we need
|
|
* to direct the user (eg /track/local:track:1235.mp3)
|
|
*
|
|
* @param $uri = String
|
|
* @param $type = String, optional
|
|
* @return String
|
|
* */
|
|
const buildLink = (uri, type = null) => {
|
|
let link = `/${type || uriType(uri)}/`;
|
|
|
|
// Encode the whole URI as though it's a component. This makes it URL friendly for
|
|
// all Mopidy backends (some use URIs like local:track:http://rss.com/stuff.mp3) which
|
|
// is never going to work nicely.
|
|
link += encodeUri(uri);
|
|
return link;
|
|
};
|
|
|
|
/**
|
|
* Detect touch-ability
|
|
*/
|
|
const isTouchDevice = function () {
|
|
return 'ontouchstart' in document.documentElement;
|
|
};
|
|
|
|
/**
|
|
* Figure out if a value is a number
|
|
* @param value = mixed
|
|
* @return boolean
|
|
* */
|
|
const isNumeric = function (value) {
|
|
return !isNaN(parseFloat(value)) && isFinite(value);
|
|
};
|
|
|
|
/**
|
|
* Figure out if a value is an object
|
|
* @param value = mixed
|
|
* @return boolean
|
|
* */
|
|
const isObject = function (value) {
|
|
return value instanceof Object && value.constructor === Object;
|
|
};
|
|
|
|
/**
|
|
* Convert an array of strings to an array of RegExp objects
|
|
*
|
|
* @param {Array} keys
|
|
*/
|
|
const toRegExp = function (keys) {
|
|
return keys.map((key) => {
|
|
try {
|
|
return new RegExp(key);
|
|
} catch {
|
|
// Fucks with unit tests, but helpful for debugging.
|
|
// console.error('Could not convert string to RegEx', key);
|
|
return null;
|
|
}
|
|
}).filter((exp) => exp); // Filter out failed conversions
|
|
};
|
|
|
|
/**
|
|
* Detect if an item is in the loading queue. We simply loop all load items to
|
|
* see if any load queue keys match our 'includes' expression AND our 'excludes' expression(s)
|
|
*
|
|
* @param {Object} load_queue (passed from store)
|
|
* @param {Array} keys array of regex strings
|
|
* @return {Boolean}
|
|
* */
|
|
const isLoading = function (load_queue = {}, keys = []) {
|
|
if (!load_queue || !keys) return false;
|
|
|
|
const expressions = toRegExp(keys);
|
|
if (!expressions.length) return false;
|
|
|
|
const queue = indexToArray(load_queue);
|
|
|
|
const matches = queue.filter((qk) => {
|
|
const matchingExpressions = keys.filter((exp) => qk.match(exp));
|
|
|
|
return (matchingExpressions.length === expressions.length);
|
|
});
|
|
|
|
return matches.length > 0;
|
|
};
|
|
|
|
/**
|
|
* Is this app running from the hosted instance?
|
|
* For example the GitHub-hosted UI
|
|
*
|
|
* @param Array hosts = valid hosted domain names
|
|
* @return Boolean
|
|
* */
|
|
const isHosted = function (hosts = ['jaedb.github.io']) {
|
|
const {
|
|
hostname,
|
|
} = window.location;
|
|
return hosts.includes(hostname);
|
|
};
|
|
|
|
/**
|
|
* Get indexed record(s) by URI from our asset index
|
|
*
|
|
* @param store = obj
|
|
* @param uris = mixed (array or string)
|
|
* @return array
|
|
* */
|
|
const getIndexedRecords = function (index, uris) {
|
|
const records = [];
|
|
|
|
// Wrap in array, if we've only got one URI
|
|
if (!(uris instanceof Array)) {
|
|
uris = [uris];
|
|
}
|
|
|
|
for (let i = 0; i < uris.length; i++) {
|
|
if (index.hasOwnProperty(uris[i])) {
|
|
records.push(index[uris[i]]);
|
|
}
|
|
}
|
|
|
|
return records;
|
|
};
|
|
|
|
/**
|
|
* Uppercase-ify the first character of a string
|
|
*
|
|
* @param string String
|
|
* @return String
|
|
* */
|
|
const titleCase = function (string = '') {
|
|
return string.charAt(0).toUpperCase() + string.slice(1);
|
|
};
|
|
|
|
/**
|
|
* Use a keyword to return an icon name
|
|
*/
|
|
const iconFromKeyword = (name) => {
|
|
const iconWords = [{
|
|
icon: 'business',
|
|
words: ['office', 'work'],
|
|
},
|
|
{
|
|
icon: 'king_bed',
|
|
words: ['bed'],
|
|
},
|
|
{
|
|
icon: 'weekend',
|
|
words: ['lounge', 'tv', 'sitting room'],
|
|
},
|
|
{
|
|
icon: 'directions_car',
|
|
words: ['garage', 'basement'],
|
|
},
|
|
{
|
|
icon: 'local_laundry_service',
|
|
words: ['laundry'],
|
|
},
|
|
{
|
|
icon: 'fitness_center',
|
|
words: ['gym'],
|
|
},
|
|
{
|
|
icon: 'kitchen',
|
|
words: ['kitchen'],
|
|
},
|
|
{
|
|
icon: 'deck',
|
|
words: ['deck', 'outside'],
|
|
},
|
|
{
|
|
icon: 'restaurant_menu',
|
|
words: ['dining', 'dinner'],
|
|
},
|
|
{
|
|
icon: 'laptop',
|
|
words: ['laptop'],
|
|
},
|
|
{
|
|
icon: 'bug_report',
|
|
words: ['test', 'debug'],
|
|
},
|
|
{
|
|
icon: 'child_care',
|
|
words: ['kids', 'baby'],
|
|
},
|
|
{
|
|
icon: 'smartphone',
|
|
words: ['phone', 'mobile'],
|
|
},
|
|
{
|
|
icon: 'web',
|
|
words: ['web', 'browser', 'iris'],
|
|
},
|
|
];
|
|
for (const item of iconWords) {
|
|
for (const word of item.words) {
|
|
if (name.match(new RegExp(`(${word})`, 'gi'))) {
|
|
return item.icon;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Scroll to the top of the page
|
|
* Our 'content' is housed in the <main> DOM element
|
|
* We make sure the target supports scrolling before we attempt it
|
|
* Safari and IE Edge, well, don't.
|
|
*
|
|
* @param target String (element ID, optional)
|
|
* @param smooth_scroll Boolean (optional)
|
|
* */
|
|
const scrollTo = function (target = null, smooth_scroll = false) {
|
|
const main = document.getElementById('main');
|
|
|
|
const performScroll = () => {
|
|
if (!smooth_scroll) main.classList.remove('smooth-scroll');
|
|
|
|
// Target is a number, so treat as pixel position
|
|
if (target && Number.isInteger(target)) {
|
|
if (typeof main.scrollTo === 'function') {
|
|
main.scrollTo(0, target);
|
|
}
|
|
|
|
// Target is a string representing a DOM element by class/id
|
|
} else if (target) {
|
|
let element = null;
|
|
|
|
if (target.charAt(0) === '#') {
|
|
element = document.getElementById(target.substring(1));
|
|
} else if (target.charAt(0) === '.') {
|
|
element = document.getElementsByClassName(target.substring(1));
|
|
if (element.length > 0) {
|
|
element = element[0];
|
|
}
|
|
} else {
|
|
console.error(`Invalid target type '${target}'. Must start with '#' or '.'.`);
|
|
}
|
|
|
|
if (element && typeof element.scrollIntoView === 'function') {
|
|
element.scrollIntoView();
|
|
}
|
|
} else {
|
|
main.scrollTop = 0;
|
|
}
|
|
|
|
if (!smooth_scroll) main.classList.add('smooth-scroll');
|
|
};
|
|
|
|
// Give .main a moment to render it's contents
|
|
setTimeout(performScroll, 1);
|
|
};
|
|
|
|
/**
|
|
* Upgrade one or many Spotify Playlist URIs
|
|
* This is their new, simplified syntax (September 2018) but they haven't updated it everywhere
|
|
* So we need to manually strip user:abc to keep things consistent
|
|
*
|
|
* @param uris Array|String
|
|
* @return Array|String
|
|
* */
|
|
const upgradeSpotifyPlaylistUris = function (uris) {
|
|
const upgraded = [];
|
|
|
|
for (let uri of uris) {
|
|
if (uri.includes('spotify:user:')) {
|
|
uri = uri.replace(/spotify:user:([^:]*?):/i, 'spotify:');
|
|
}
|
|
upgraded.push(uri);
|
|
}
|
|
|
|
return upgraded;
|
|
};
|
|
|
|
// As above, but for a single URI
|
|
const upgradeSpotifyPlaylistUri = function (uri) {
|
|
return upgradeSpotifyPlaylistUris([uri])[0];
|
|
};
|
|
|
|
const getSearchResultKey = ({ provider, type, term }) => [provider, type, term].join(':');
|
|
|
|
export {
|
|
debounce,
|
|
throttle,
|
|
setFavicon,
|
|
queryString,
|
|
generateGuid,
|
|
getCurrentPusherConnection,
|
|
uriSource,
|
|
uriType,
|
|
sourceIcon,
|
|
getFromUri,
|
|
buildLink,
|
|
isTouchDevice,
|
|
isNumeric,
|
|
isObject,
|
|
isLoading,
|
|
isHosted,
|
|
getIndexedRecords,
|
|
titleCase,
|
|
scrollTo,
|
|
upgradeSpotifyPlaylistUris,
|
|
upgradeSpotifyPlaylistUri,
|
|
iconFromKeyword,
|
|
getSearchResultKey,
|
|
};
|
|
|
|
export default {
|
|
debounce,
|
|
throttle,
|
|
setFavicon,
|
|
queryString,
|
|
generateGuid,
|
|
getCurrentPusherConnection,
|
|
uriSource,
|
|
uriType,
|
|
sourceIcon,
|
|
getFromUri,
|
|
buildLink,
|
|
isTouchDevice,
|
|
isNumeric,
|
|
isObject,
|
|
isLoading,
|
|
isHosted,
|
|
getIndexedRecords,
|
|
titleCase,
|
|
scrollTo,
|
|
upgradeSpotifyPlaylistUris,
|
|
upgradeSpotifyPlaylistUri,
|
|
iconFromKeyword,
|
|
getSearchResultKey,
|
|
};
|