Files
Iris/src/js/util/helpers.js
Max Goltzsche ad6b3d5d5a Add Subidy and WebM3U support
* 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).
2024-03-14 04:32:50 +01:00

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,
};