import appHost from 'apphost'; import appSettings from 'appSettings'; import backdrop from 'backdrop'; import browser from 'browser'; import connectionManager from 'connectionManager'; import events from 'events'; import globalize from 'globalize'; import itemHelper from 'itemHelper'; import loading from 'loading'; import page from 'page'; import viewManager from 'viewManager'; class AppRouter { allRoutes = []; backdropContainer; backgroundContainer; currentRouteInfo; currentViewLoadRequest; firstConnectionResult; forcedLogoutMsg; handleAnchorClick = page.clickHandler; isDummyBackToHome; msgTimeout; popstateOccurred = false; resolveOnNextShow; /** * Pages of "no return" (when "Go back" should behave differently, probably quitting the application). */ startPages = ['home', 'login', 'selectserver']; constructor() { window.addEventListener('popstate', () => { this.popstateOccurred = true; }); document.addEventListener('viewshow', () => { const resolve = this.resolveOnNextShow; if (resolve) { this.resolveOnNextShow = null; resolve(); } }); this.baseRoute = self.location.href.split('?')[0].replace(this.getRequestFile(), ''); // support hashbang this.baseRoute = this.baseRoute.split('#')[0]; if (this.baseRoute.endsWith('/') && !this.baseRoute.endsWith('://')) { this.baseRoute = this.baseRoute.substring(0, this.baseRoute.length - 1); } this.setBaseRoute(); } /** * @private */ setBaseRoute() { let baseRoute = self.location.pathname.replace(this.getRequestFile(), ''); if (baseRoute.lastIndexOf('/') === baseRoute.length - 1) { baseRoute = baseRoute.substring(0, baseRoute.length - 1); } console.debug('setting page base to ' + baseRoute); page.base(baseRoute); } addRoute(path, newRoute) { page(path, this.getHandler(newRoute)); this.allRoutes.push(newRoute); } showLocalLogin(serverId) { Dashboard.navigate('login.html?serverid=' + serverId); } showVideoOsd() { return Dashboard.navigate('video'); } showSelectServer() { Dashboard.navigate(AppInfo.isNativeApp ? 'selectserver.html' : 'login.html'); } showWelcome() { Dashboard.navigate(AppInfo.isNativeApp ? 'selectserver.html' : 'login.html'); } showSettings() { Dashboard.navigate('mypreferencesmenu.html'); } showNowPlaying() { this.show('queue'); } beginConnectionWizard() { backdrop.clearBackdrop(); loading.show(); connectionManager.connect({ enableAutoLogin: appSettings.enableAutoLogin() }).then((result) => { this.handleConnectionResult(result); }); } param(name, url) { name = name.replace(/[[]/, '\\[').replace(/[\]]/, '\\]'); const regexS = '[\\?&]' + name + '=([^&#]*)'; const regex = new RegExp(regexS, 'i'); const results = regex.exec(url || getWindowLocationSearch()); if (results == null) { return ''; } else { return decodeURIComponent(results[1].replace(/\+/g, ' ')); } } back() { page.back(); } show(path, options) { if (path.indexOf('/') !== 0 && path.indexOf('://') === -1) { path = '/' + path; } path = path.replace(this.baseUrl(), ''); if (this.currentRouteInfo && this.currentRouteInfo.path === path) { // can't use this with home right now due to the back menu if (this.currentRouteInfo.route.type !== 'home') { loading.hide(); return Promise.resolve(); } } return new Promise((resolve) => { this.resolveOnNextShow = resolve; page.show(path, options); }); } showDirect(path) { return new Promise(function(resolve) { this.resolveOnNextShow = resolve; page.show(this.baseUrl() + path); }); } start(options) { loading.show(); this.initApiClients(); events.on(appHost, 'beforeexit', this.onBeforeExit); events.on(appHost, 'resume', this.onAppResume); connectionManager.connect({ enableAutoLogin: appSettings.enableAutoLogin() }).then((result) => { this.firstConnectionResult = result; options = options || {}; page({ click: options.click !== false, hashbang: options.hashbang !== false }); }).catch().then(() => { loading.hide(); }); } baseUrl() { return this.baseRoute; } canGoBack() { const curr = this.current(); if (!curr) { return false; } if (!document.querySelector('.dialogContainer') && this.startPages.indexOf(curr.type) !== -1) { return false; } return history.length > 1; } current() { return this.currentRouteInfo ? this.currentRouteInfo.route : null; } invokeShortcut(id) { if (id.indexOf('library-') === 0) { id = id.replace('library-', ''); id = id.split('_'); this.showItem(id[0], id[1]); } else if (id.indexOf('item-') === 0) { id = id.replace('item-', ''); id = id.split('_'); this.showItem(id[0], id[1]); } else { id = id.split('_'); this.show(this.getRouteUrl(id[0], { serverId: id[1] })); } } showItem(item, serverId, options) { // TODO: Refactor this so it only gets items, not strings. if (typeof (item) === 'string') { const apiClient = serverId ? connectionManager.getApiClient(serverId) : connectionManager.currentApiClient(); apiClient.getItem(apiClient.getCurrentUserId(), item).then((itemObject) => { this.showItem(itemObject, options); }); } else { if (arguments.length === 2) { options = arguments[1]; } const url = this.getRouteUrl(item, options); this.show(url, { item: item }); } } setTransparency(level) { if (!this.backdropContainer) { this.backdropContainer = document.querySelector('.backdropContainer'); } if (!this.backgroundContainer) { this.backgroundContainer = document.querySelector('.backgroundContainer'); } if (level === 'full' || level === 2) { backdrop.clearBackdrop(true); document.documentElement.classList.add('transparentDocument'); this.backgroundContainer.classList.add('backgroundContainer-transparent'); this.backdropContainer.classList.add('hide'); } else if (level === 'backdrop' || level === 1) { backdrop.externalBackdrop(true); document.documentElement.classList.add('transparentDocument'); this.backgroundContainer.classList.add('backgroundContainer-transparent'); this.backdropContainer.classList.add('hide'); } else { backdrop.externalBackdrop(false); document.documentElement.classList.remove('transparentDocument'); this.backgroundContainer.classList.remove('backgroundContainer-transparent'); this.backdropContainer.classList.remove('hide'); } } getRoutes() { return this.allRoutes; } pushState(state, title, url) { state.navigate = false; history.pushState(state, title, url); } enableNativeHistory() { return false; } handleConnectionResult(result) { switch (result.State) { case 'SignedIn': loading.hide(); Emby.Page.goHome(); break; case 'ServerSignIn': result.ApiClient.getPublicUsers().then((users) => { if (users.length) { this.showLocalLogin(result.Servers[0].Id); } else { this.showLocalLogin(result.Servers[0].Id, true); } }); break; case 'ServerSelection': this.showSelectServer(); break; case 'ConnectSignIn': this.showWelcome(); break; case 'ServerUpdateNeeded': import('alert').then(({default: alert}) =>{ alert({ text: globalize.translate('ServerUpdateNeeded', 'https://github.com/jellyfin/jellyfin'), html: globalize.translate('ServerUpdateNeeded', 'https://github.com/jellyfin/jellyfin') }).then(() => { this.showSelectServer(); }); }); break; default: break; } } loadContentUrl(ctx, next, route, request) { let url; if (route.contentPath && typeof (route.contentPath) === 'function') { url = route.contentPath(ctx.querystring); } else { url = route.contentPath || route.path; } if (url.indexOf('://') === -1) { // Put a slash at the beginning but make sure to avoid a double slash if (url.indexOf('/') !== 0) { url = '/' + url; } url = this.baseUrl() + url; } if (ctx.querystring && route.enableContentQueryString) { url += '?' + ctx.querystring; } import('text!' + url).then(({default: html}) => { this.loadContent(ctx, route, html, request); }); } handleRoute(ctx, next, route) { this.authenticate(ctx, route, () => { this.initRoute(ctx, next, route); }); } initRoute(ctx, next, route) { const onInitComplete = (controllerFactory) => { this.sendRouteToViewManager(ctx, next, route, controllerFactory); }; if (route.controller) { import('controllers/' + route.controller).then(onInitComplete); } else { onInitComplete(); } } cancelCurrentLoadRequest() { const currentRequest = this.currentViewLoadRequest; if (currentRequest) { currentRequest.cancel = true; } } sendRouteToViewManager(ctx, next, route, controllerFactory) { if (this.isDummyBackToHome && route.type === 'home') { this.isDummyBackToHome = false; return; } this.cancelCurrentLoadRequest(); const isBackNav = ctx.isBack; const currentRequest = { url: this.baseUrl() + ctx.path, transition: route.transition, isBack: isBackNav, state: ctx.state, type: route.type, fullscreen: route.fullscreen, controllerFactory: controllerFactory, options: { supportsThemeMedia: route.supportsThemeMedia || false, enableMediaControl: route.enableMediaControl !== false }, autoFocus: route.autoFocus }; this.currentViewLoadRequest = currentRequest; const onNewViewNeeded = () => { if (typeof route.path === 'string') { this.loadContentUrl(ctx, next, route, currentRequest); } else { next(); } }; if (!isBackNav) { onNewViewNeeded(); return; } viewManager.tryRestoreView(currentRequest, () => { this.currentRouteInfo = { route: route, path: ctx.path }; }).catch((result) => { if (!result || !result.cancelled) { onNewViewNeeded(); } }); } onForcedLogoutMessageTimeout() { const msg = this.forcedLogoutMsg; this.forcedLogoutMsg = null; if (msg) { import('alert').then((alert) => { alert(msg); }); } } showForcedLogoutMessage(msg) { this.forcedLogoutMsg = msg; if (this.msgTimeout) { clearTimeout(this.msgTimeout); } this.msgTimeout = setTimeout(this.onForcedLogoutMessageTimeout, 100); } onRequestFail(e, data) { const apiClient = this; if (data.status === 403) { if (data.errorCode === 'ParentalControl') { const isCurrentAllowed = this.currentRouteInfo ? (this.currentRouteInfo.route.anonymous || this.currentRouteInfo.route.startup) : true; // Bounce to the login screen, but not if a password entry fails, obviously if (!isCurrentAllowed) { this.showForcedLogoutMessage(globalize.translate('AccessRestrictedTryAgainLater')); this.showLocalLogin(apiClient.serverId()); } } } } onBeforeExit() { if (browser.web0s) { page.restorePreviousState(); } } normalizeImageOptions(options) { let setQuality; if (options.maxWidth || options.width || options.maxHeight || options.height) { setQuality = true; } if (setQuality && !options.quality) { options.quality = 90; } } getMaxBandwidth() { /* eslint-disable compat/compat */ if (navigator.connection) { let max = navigator.connection.downlinkMax; if (max && max > 0 && max < Number.POSITIVE_INFINITY) { max /= 8; max *= 1000000; max *= 0.7; return parseInt(max, 10); } } /* eslint-enable compat/compat */ return null; } getMaxBandwidthIOS() { return 800000; } onApiClientCreated(e, newApiClient) { newApiClient.normalizeImageOptions = this.normalizeImageOptions; if (browser.iOS) { newApiClient.getMaxBandwidth = this.getMaxBandwidthIOS; } else { newApiClient.getMaxBandwidth = this.getMaxBandwidth; } events.off(newApiClient, 'requestfail', this.onRequestFail); events.on(newApiClient, 'requestfail', this.onRequestFail); } initApiClient(apiClient, instance) { instance.onApiClientCreated({}, apiClient); } initApiClients() { connectionManager.getApiClients().forEach((apiClient) => { this.initApiClient(apiClient, this); }); events.on(connectionManager, 'apiclientcreated', this.onApiClientCreated); } onAppResume() { const apiClient = connectionManager.currentApiClient(); if (apiClient) { apiClient.ensureWebSocket(); } } authenticate(ctx, route, callback) { const firstResult = this.firstConnectionResult; if (firstResult) { this.firstConnectionResult = null; if (firstResult.State !== 'SignedIn' && !route.anonymous) { this.handleConnectionResult(firstResult); return; } } const apiClient = connectionManager.currentApiClient(); const pathname = ctx.pathname.toLowerCase(); console.debug('appRouter - processing path request ' + pathname); const isCurrentRouteStartup = this.currentRouteInfo ? this.currentRouteInfo.route.startup : true; const shouldExitApp = ctx.isBack && route.isDefaultRoute && isCurrentRouteStartup; if (!shouldExitApp && (!apiClient || !apiClient.isLoggedIn()) && !route.anonymous) { console.debug('appRouter - route does not allow anonymous access, redirecting to login'); this.beginConnectionWizard(); return; } if (shouldExitApp) { if (appHost.supports('exit')) { appHost.exit(); return; } return; } if (apiClient && apiClient.isLoggedIn()) { console.debug('appRouter - user is authenticated'); if (route.isDefaultRoute) { console.debug('appRouter - loading skin home page'); Emby.Page.goHome(); return; } else if (route.roles) { this.validateRoles(apiClient, route.roles).then(() => { callback(); }, this.beginConnectionWizard); return; } } console.debug('appRouter - proceeding to ' + pathname); callback(); } validateRoles(apiClient, roles) { return Promise.all(roles.split(',').map((role) => { return this.validateRole(apiClient, role); })); } validateRole(apiClient, role) { if (role === 'admin') { return apiClient.getCurrentUser().then((user) => { if (user.Policy.IsAdministrator) { return Promise.resolve(); } return Promise.reject(); }); } // Unknown role return Promise.resolve(); } loadContent(ctx, route, html, request) { html = globalize.translateHtml(html, route.dictionary); request.view = html; viewManager.loadView(request); this.currentRouteInfo = { route: route, path: ctx.path }; ctx.handled = true; } getRequestFile() { let path = self.location.pathname || ''; const index = path.lastIndexOf('/'); if (index !== -1) { path = path.substring(index); } else { path = '/' + path; } if (!path || path === '/') { path = '/index.html'; } return path; } getHandler(route) { return (ctx, next) => { ctx.isBack = this.popstateOccurred; this.handleRoute(ctx, next, route); this.popstateOccurred = false; }; } getWindowLocationSearch() { const currentPath = this.currentRouteInfo ? (this.currentRouteInfo.path || '') : ''; const index = currentPath.indexOf('?'); let search = ''; if (index !== -1) { search = currentPath.substring(index); } return search || ''; } showGuide() { Dashboard.navigate('livetv.html?tab=1'); } goHome() { Dashboard.navigate('home.html'); } showSearch() { Dashboard.navigate('search.html'); } showLiveTV() { Dashboard.navigate('livetv.html'); } showRecordedTV() { Dashboard.navigate('livetv.html?tab=3'); } showFavorites() { Dashboard.navigate('home.html?tab=1'); } setTitle(title) { LibraryMenu.setTitle(title); } getRouteUrl(item, options) { if (!item) { throw new Error('item cannot be null'); } if (item.url) { return item.url; } const context = options ? options.context : null; const id = item.Id || item.ItemId; if (!options) { options = {}; } let url; // TODO: options will never be false. Replace condition with lodash's isEmpty() const itemType = item.Type || (options ? options.itemType : null); const serverId = item.ServerId || options.serverId; if (item === 'settings') { return 'mypreferencesmenu.html'; } if (item === 'wizard') { return 'wizardstart.html'; } if (item === 'manageserver') { return 'dashboard.html'; } if (item === 'recordedtv') { return 'livetv.html?tab=3&serverId=' + options.serverId; } if (item === 'nextup') { return 'list.html?type=nextup&serverId=' + options.serverId; } if (item === 'list') { let url = 'list.html?serverId=' + options.serverId + '&type=' + options.itemTypes; if (options.isFavorite) { url += '&IsFavorite=true'; } return url; } if (item === 'livetv') { if (options.section === 'programs') { return 'livetv.html?tab=0&serverId=' + options.serverId; } if (options.section === 'guide') { return 'livetv.html?tab=1&serverId=' + options.serverId; } if (options.section === 'movies') { return 'list.html?type=Programs&IsMovie=true&serverId=' + options.serverId; } if (options.section === 'shows') { return 'list.html?type=Programs&IsSeries=true&IsMovie=false&IsNews=false&serverId=' + options.serverId; } if (options.section === 'sports') { return 'list.html?type=Programs&IsSports=true&serverId=' + options.serverId; } if (options.section === 'kids') { return 'list.html?type=Programs&IsKids=true&serverId=' + options.serverId; } if (options.section === 'news') { return 'list.html?type=Programs&IsNews=true&serverId=' + options.serverId; } if (options.section === 'onnow') { return 'list.html?type=Programs&IsAiring=true&serverId=' + options.serverId; } if (options.section === 'dvrschedule') { return 'livetv.html?tab=4&serverId=' + options.serverId; } if (options.section === 'seriesrecording') { return 'livetv.html?tab=5&serverId=' + options.serverId; } return 'livetv.html?serverId=' + options.serverId; } if (itemType == 'SeriesTimer') { return 'details?seriesTimerId=' + id + '&serverId=' + serverId; } if (item.CollectionType == 'livetv') { return 'livetv.html'; } if (item.Type === 'Genre') { url = 'list.html?genreId=' + item.Id + '&serverId=' + serverId; if (context === 'livetv') { url += '&type=Programs'; } if (options.parentId) { url += '&parentId=' + options.parentId; } return url; } if (item.Type === 'MusicGenre') { url = 'list.html?musicGenreId=' + item.Id + '&serverId=' + serverId; if (options.parentId) { url += '&parentId=' + options.parentId; } return url; } if (item.Type === 'Studio') { url = 'list.html?studioId=' + item.Id + '&serverId=' + serverId; if (options.parentId) { url += '&parentId=' + options.parentId; } return url; } if (context !== 'folders' && !itemHelper.isLocalItem(item)) { if (item.CollectionType == 'movies') { url = 'movies.html?topParentId=' + item.Id; if (options && options.section === 'latest') { url += '&tab=1'; } return url; } if (item.CollectionType == 'tvshows') { url = 'tv.html?topParentId=' + item.Id; if (options && options.section === 'latest') { url += '&tab=2'; } return url; } if (item.CollectionType == 'music') { return 'music.html?topParentId=' + item.Id; } } const itemTypes = ['Playlist', 'TvChannel', 'Program', 'BoxSet', 'MusicAlbum', 'MusicGenre', 'Person', 'Recording', 'MusicArtist']; if (itemTypes.indexOf(itemType) >= 0) { return 'details?id=' + id + '&serverId=' + serverId; } const contextSuffix = context ? '&context=' + context : ''; if (itemType == 'Series' || itemType == 'Season' || itemType == 'Episode') { return 'details?id=' + id + contextSuffix + '&serverId=' + serverId; } if (item.IsFolder) { if (id) { return 'list.html?parentId=' + id + '&serverId=' + serverId; } return '#'; } return 'details?id=' + id + '&serverId=' + serverId; } } export default new AppRouter();