diff --git a/src/app.ts b/src/app.ts index 18b4dd16..c63630c3 100644 --- a/src/app.ts +++ b/src/app.ts @@ -3,20 +3,6 @@ import './components/maincontroller'; import './css/glyphicons.css'; import './css/jellyfin.css'; -const senders = cast.framework.CastReceiverContext.getInstance().getSenders(); -const id = - senders.length !== 0 && senders[0].id - ? senders[0].id - : new Date().getTime(); - -window.deviceInfo = { - deviceId: id, - deviceName: 'Google Cast', - versionNumber: RECEIVERVERSION -}; - window.mediaElement = document.getElementById('video-player'); -window.playlist = []; -window.currentPlaylistIndex = -1; window.repeatMode = RepeatMode.RepeatNone; diff --git a/src/components/codecSupportHelper.ts b/src/components/codecSupportHelper.ts index bc4ffb2f..bf15e024 100644 --- a/src/components/codecSupportHelper.ts +++ b/src/components/codecSupportHelper.ts @@ -102,22 +102,28 @@ export function getMaxBitrateSupport(): number { /** * Get the max supported video width the active Cast device supports. * - * @param deviceId - Cast device id. * @returns Max supported width. */ -export function getMaxWidthSupport(deviceId: number): number { - switch (deviceId) { - case deviceIds.ULTRA: - case deviceIds.CCGTV: - return 3840; - case deviceIds.GEN1AND2: - case deviceIds.GEN3: - return 1920; - case deviceIds.NESTHUBANDMAX: - return 1280; - } - - return 0; +export function getMaxWidthSupport(): number { + // with HLS, it will produce a manifest error if we + // send any stream larger than the screen size... + return window.innerWidth; + + // mkv playback can use the device limitations. + // The devices are capable of decoding and downscaling, + // they just refuse to do it with HLS. This increases + // the rate of direct playback. + //switch (deviceId) { + // case deviceIds.ULTRA: + // case deviceIds.CCGTV: + // return 3840; + // case deviceIds.GEN1AND2: + // case deviceIds.GEN3: + // return 1920; + // case deviceIds.NESTHUBANDMAX: + // return 1280; + //} + //return 0; } /** diff --git a/src/components/commandHandler.ts b/src/components/commandHandler.ts index 0e69dd1e..84912a89 100644 --- a/src/components/commandHandler.ts +++ b/src/components/commandHandler.ts @@ -19,13 +19,12 @@ import { import { reportPlaybackProgress } from './jellyfinActions'; -import { playbackManager } from './playbackManager'; +import { PlaybackManager } from './playbackManager'; import { DocumentManager } from './documentManager'; export abstract class CommandHandler { private static playerManager: framework.PlayerManager; - private static playbackManager: playbackManager; private static supportedCommands: SupportedCommands = { DisplayContent: CommandHandler.displayContentHandler, Identify: CommandHandler.IdentifyHandler, @@ -52,12 +51,8 @@ export abstract class CommandHandler { VolumeUp: CommandHandler.VolumeUpHandler }; - static configure( - playerManager: framework.PlayerManager, - playbackManager: playbackManager - ): void { + static configure(playerManager: framework.PlayerManager): void { this.playerManager = playerManager; - this.playbackManager = playbackManager; } static playNextHandler(data: DataMessage): void { @@ -89,23 +84,20 @@ export abstract class CommandHandler { } static displayContentHandler(data: DataMessage): void { - if (!this.playbackManager.isPlaying()) { + if (!PlaybackManager.isPlaying()) { DocumentManager.showItemId((data.options).ItemId); } } static nextTrackHandler(): void { - if ( - window.playlist && - window.currentPlaylistIndex < window.playlist.length - 1 - ) { - this.playbackManager.playNextItem({}, true); + if (PlaybackManager.hasNextItem()) { + PlaybackManager.playNextItem({}, true); } } static previousTrackHandler(): void { - if (window.playlist && window.currentPlaylistIndex > 0) { - this.playbackManager.playPreviousItem({}); + if (PlaybackManager.hasPrevItem()) { + PlaybackManager.playPreviousItem({}); } } @@ -138,7 +130,8 @@ export abstract class CommandHandler { } static IdentifyHandler(): void { - if (!this.playbackManager.isPlaying()) { + if (!PlaybackManager.isPlaying()) { + DocumentManager.setAppStatus('waiting'); DocumentManager.startBackdropInterval(); } else { // When a client connects send back the initial device state (volume etc) via a playbackstop message diff --git a/src/components/deviceprofileBuilder.ts b/src/components/deviceprofileBuilder.ts index 7ba74082..52afb353 100644 --- a/src/components/deviceprofileBuilder.ts +++ b/src/components/deviceprofileBuilder.ts @@ -195,7 +195,7 @@ function getCodecProfiles(): Array { CodecProfiles.push(aacConditions); - const maxWidth: number = getMaxWidthSupport(currentDeviceId); + const maxWidth: number = getMaxWidthSupport(); const h26xLevel: number = getH26xLevelSupport(currentDeviceId); const h26xProfile: string = getH26xProfileSupport(currentDeviceId); diff --git a/src/components/jellyfinActions.ts b/src/components/jellyfinActions.ts index 0d408bba..a94a30b6 100644 --- a/src/components/jellyfinActions.ts +++ b/src/components/jellyfinActions.ts @@ -1,7 +1,6 @@ import { getSenderReportingData, resetPlaybackScope, - extend, broadcastToMessageBus } from '../helpers'; @@ -199,12 +198,22 @@ export function pingTranscoder( */ export function load( $scope: GlobalScope, - customData: PlaybackProgressInfo, + customData: any, serverItem: BaseItemDto ): void { resetPlaybackScope($scope); - extend($scope, customData); + // These are set up in maincontroller.createMediaInformation + $scope.playSessionId = customData.playSessionId; + $scope.audioStreamIndex = customData.audioStreamIndex; + $scope.subtitleStreamIndex = customData.subtitleStreamIndex; + $scope.startPositionTicks = customData.startPositionTicks; + $scope.canSeek = customData.canSeek; + $scope.itemId = customData.itemId; + $scope.liveStreamId = customData.liveStreamId; + $scope.mediaSourceId = customData.mediaSourceId; + $scope.playMethod = customData.playMethod; + $scope.runtimeTicks = customData.runtimeTicks; $scope.item = serverItem; @@ -230,7 +239,7 @@ export function play($scope: GlobalScope): void { DocumentManager.getAppStatus() == 'audio' ) { setTimeout(() => { - window.mediaManager.play(); + window.playerManager.play(); if ($scope.mediaType == 'Audio') { DocumentManager.setAppStatus('audio'); @@ -241,15 +250,6 @@ export function play($scope: GlobalScope): void { } } -/** - * Don't actually stop, just show the idle view after 20ms - */ -export function stop(): void { - setTimeout(() => { - DocumentManager.setAppStatus('waiting'); - }, 20); -} - /** * @param item * @param maxBitrate @@ -369,11 +369,14 @@ export async function getDownloadSpeed(byteSize: number): Promise { const now = new Date().getTime(); - await JellyfinApi.authAjax(path, { + const response = await JellyfinApi.authAjax(path, { timeout: 5000, type: 'GET' }); + // Force javascript to download the whole response before calculating bitrate + await response.blob(); + const responseTimeSeconds = (new Date().getTime() - now) / 1000; const bytesPerSecond = byteSize / responseTimeSeconds; const bitrate = Math.round(bytesPerSecond * 8); @@ -383,22 +386,29 @@ export async function getDownloadSpeed(byteSize: number): Promise { /** * Function to detect the bitrate. - * It first tries 1MB and if bitrate is above 1Mbit/s it tries again with 2.4MB. + * It starts at 500kB and doubles it every time it takes under 2s, for max 10MB. + * This should get an accurate bitrate relatively fast on any connection * + * @param numBytes - Number of bytes to start with, default 500k * @returns bitrate in bits/s */ -export async function detectBitrate(): Promise { - // First try a small amount so that we don't hang up their mobile connection +export async function detectBitrate(numBytes = 500000): Promise { + // Jellyfin has a 10MB limit on the test size + const byteLimit = 10000000; - let bitrate = await getDownloadSpeed(1000000); - - if (bitrate < 1000000) { - return Math.round(bitrate * 0.8); + if (numBytes > byteLimit) { + numBytes = byteLimit; } - bitrate = await getDownloadSpeed(2400000); + const bitrate = await getDownloadSpeed(numBytes); - return Math.round(bitrate * 0.8); + if (bitrate * (2 / 8.0) < numBytes || numBytes >= byteLimit) { + // took > 2s, or numBytes hit the limit + return Math.round(bitrate * 0.8); + } else { + // If that produced a fairly high speed, try again with a larger size to get a more accurate result + return await detectBitrate(numBytes * 2); + } } /** @@ -409,7 +419,7 @@ export async function detectBitrate(): Promise { */ export function stopActiveEncodings($scope: GlobalScope): Promise { const options = { - deviceId: window.deviceInfo.deviceId, + deviceId: JellyfinApi.deviceId, PlaySessionId: undefined }; diff --git a/src/components/jellyfinApi.ts b/src/components/jellyfinApi.ts index 8221ea07..07e43e19 100644 --- a/src/components/jellyfinApi.ts +++ b/src/components/jellyfinApi.ts @@ -11,17 +11,46 @@ export abstract class JellyfinApi { // Address of server public static serverAddress: string | null = null; + // device name + public static deviceName = 'Google Cast'; + + // unique id + public static deviceId = ''; + + // version + public static versionNumber = RECEIVERVERSION; + public static setServerInfo( userId: string, accessToken: string, - serverAddress: string + serverAddress: string, + receiverName: string ): void { console.debug( - `JellyfinApi.setServerInfo: user:${userId}, token:${accessToken}, server:${serverAddress}` + `JellyfinApi.setServerInfo: user:${userId}, token:${accessToken}, ` + + `server:${serverAddress}, name:${receiverName}` ); this.userId = userId; this.accessToken = accessToken; this.serverAddress = serverAddress; + + // remove special characters from the receiver name + receiverName = receiverName.replace(/[^\w\s]/gi, ''); + + if (receiverName) { + this.deviceName = receiverName; + // deviceId just needs to be unique-ish + this.deviceId = btoa(receiverName); + } else { + const senders = + cast.framework.CastReceiverContext.getInstance().getSenders(); + + this.deviceName = 'Google Cast'; + this.deviceId = + senders.length !== 0 && senders[0].id + ? senders[0].id + : new Date().getTime().toString(); + } } // create the necessary headers for authentication @@ -29,10 +58,8 @@ export abstract class JellyfinApi { // TODO throw error if this fails let auth = - `Emby Client="Chromecast", ` + - `Device="${window.deviceInfo.deviceName}", ` + - `DeviceId="${window.deviceInfo.deviceId}", ` + - `Version="${window.deviceInfo.versionNumber}"`; + `Emby Client="Chromecast", Device="${this.deviceName}", ` + + `DeviceId="${this.deviceId}", Version="${this.versionNumber}"`; if (this.userId) { auth += `, UserId="${this.userId}"`; diff --git a/src/components/maincontroller.ts b/src/components/maincontroller.ts index 21f93a45..9ff5ebf8 100644 --- a/src/components/maincontroller.ts +++ b/src/components/maincontroller.ts @@ -3,45 +3,38 @@ import { getReportingParams, resetPlaybackScope, getMetadata, - createStreamInfo, getStreamByIndex, getShuffleItems, getInstantMixItems, translateRequestedItems, - extend, broadcastToMessageBus, - broadcastConnectionErrorMessage, - cleanName + broadcastConnectionErrorMessage } from '../helpers'; import { + reportPlaybackStart, reportPlaybackProgress, reportPlaybackStopped, play, - getPlaybackInfo, - stopActiveEncodings, detectBitrate } from './jellyfinActions'; import { getDeviceProfile } from './deviceprofileBuilder'; import { JellyfinApi } from './jellyfinApi'; -import { playbackManager } from './playbackManager'; +import { PlaybackManager } from './playbackManager'; import { CommandHandler } from './commandHandler'; import { getMaxBitrateSupport } from './codecSupportHelper'; -import { DocumentManager } from './documentManager'; import { BaseItemDto } from '~/api/generated/models/base-item-dto'; import { MediaSourceInfo } from '~/api/generated/models/media-source-info'; import { GlobalScope, PlayRequest } from '~/types/global'; window.castReceiverContext = cast.framework.CastReceiverContext.getInstance(); -window.mediaManager = window.castReceiverContext.getPlayerManager(); +window.playerManager = window.castReceiverContext.getPlayerManager(); -const playbackMgr = new playbackManager(window.mediaManager); +PlaybackManager.setPlayerManager(window.playerManager); -CommandHandler.configure(window.mediaManager, playbackMgr); +CommandHandler.configure(window.playerManager); resetPlaybackScope($scope); -const mgr = window.mediaManager; - let broadcastToServer = new Date(); let hasReportedCapabilities = false; @@ -106,7 +99,7 @@ function onMediaElementVolumeChange(event: framework.system.Event): void { * */ export function enableTimeUpdateListener(): void { - window.mediaManager.addEventListener( + window.playerManager.addEventListener( cast.framework.events.EventType.TIME_UPDATE, onMediaElementTimeUpdate ); @@ -114,11 +107,11 @@ export function enableTimeUpdateListener(): void { cast.framework.system.EventType.SYSTEM_VOLUME_CHANGED, onMediaElementVolumeChange ); - window.mediaManager.addEventListener( + window.playerManager.addEventListener( cast.framework.events.EventType.PAUSE, onMediaElementPause ); - window.mediaManager.addEventListener( + window.playerManager.addEventListener( cast.framework.events.EventType.PLAYING, onMediaElementPlaying ); @@ -128,7 +121,7 @@ export function enableTimeUpdateListener(): void { * */ export function disableTimeUpdateListener(): void { - window.mediaManager.removeEventListener( + window.playerManager.removeEventListener( cast.framework.events.EventType.TIME_UPDATE, onMediaElementTimeUpdate ); @@ -136,11 +129,11 @@ export function disableTimeUpdateListener(): void { cast.framework.system.EventType.SYSTEM_VOLUME_CHANGED, onMediaElementVolumeChange ); - window.mediaManager.removeEventListener( + window.playerManager.removeEventListener( cast.framework.events.EventType.PAUSE, onMediaElementPause ); - window.mediaManager.removeEventListener( + window.playerManager.removeEventListener( cast.framework.events.EventType.PLAYING, onMediaElementPlaying ); @@ -154,50 +147,75 @@ window.addEventListener('beforeunload', () => { reportPlaybackStopped($scope, getReportingParams($scope)); }); -mgr.addEventListener(cast.framework.events.EventType.PLAY, (): void => { - play($scope); - reportPlaybackProgress($scope, getReportingParams($scope)); -}); +window.playerManager.addEventListener( + cast.framework.events.EventType.PLAY, + (): void => { + play($scope); + } +); -mgr.addEventListener(cast.framework.events.EventType.PAUSE, (): void => { - reportPlaybackProgress($scope, getReportingParams($scope)); -}); +window.playerManager.addEventListener( + cast.framework.events.EventType.PAUSE, + (): void => { + reportPlaybackProgress($scope, getReportingParams($scope)); + } +); /** * */ -function defaultOnStop(): void { - playbackMgr.stop(); +function defaultOnStopped(): void { + PlaybackManager.onStopped(); } -mgr.addEventListener( +window.playerManager.addEventListener( cast.framework.events.EventType.MEDIA_FINISHED, - defaultOnStop + defaultOnStopped +); +window.playerManager.addEventListener( + cast.framework.events.EventType.ABORT, + defaultOnStopped ); -mgr.addEventListener(cast.framework.events.EventType.ABORT, defaultOnStop); -mgr.addEventListener(cast.framework.events.EventType.ENDED, () => { - // Ignore - if ($scope.isChangingStream) { - return; - } +window.playerManager.addEventListener( + cast.framework.events.EventType.ENDED, + (): void => { + // Ignore + if ($scope.isChangingStream) { + return; + } - reportPlaybackStopped($scope, getReportingParams($scope)); - resetPlaybackScope($scope); + reportPlaybackStopped($scope, getReportingParams($scope)); + resetPlaybackScope($scope); - if (!playbackMgr.playNextItem()) { - window.playlist = []; - window.currentPlaylistIndex = -1; - DocumentManager.startBackdropInterval(); + if (!PlaybackManager.playNextItem()) { + PlaybackManager.resetPlaylist(); + PlaybackManager.onStopped(); + } } -}); +); + +// Notify of playback start as soon as the media is playing. Only then is the tick position good. +window.playerManager.addEventListener( + cast.framework.events.EventType.PLAYING, + (): void => { + reportPlaybackStart($scope, getReportingParams($scope)); + } +); +// Notify of playback end just before stopping it, to get a good tick position +window.playerManager.addEventListener( + cast.framework.events.EventType.REQUEST_STOP, + (): void => { + reportPlaybackStopped($scope, getReportingParams($scope)); + } +); // Set the active subtitle track once the player has loaded -window.mediaManager.addEventListener( +window.playerManager.addEventListener( cast.framework.events.EventType.PLAYER_LOAD_COMPLETE, () => { setTextTrack( - window.mediaManager.getMediaInformation().customData + window.playerManager.getMediaInformation().customData .subtitleStreamIndex ); } @@ -251,38 +269,30 @@ export function processMessage(data: any): void { return; } + data.options = data.options || {}; + // Items will have properties - Id, Name, Type, MediaType, IsFolder JellyfinApi.setServerInfo( data.userId, data.accessToken, - data.serverAddress + data.serverAddress, + data.receiverName || '' ); if (data.subtitleAppearance) { window.subtitleAppearance = data.subtitleAppearance; } + if (data.maxBitrate) { + window.MaxBitrate = data.maxBitrate; + } + // Report device capabilities if (!hasReportedCapabilities) { reportDeviceCapabilities(); } - data.options = data.options || {}; - - const cleanReceiverName = cleanName(data.receiverName || ''); - - window.deviceInfo.deviceName = - cleanReceiverName || window.deviceInfo.deviceName; - // deviceId just needs to be unique-ish - window.deviceInfo.deviceId = cleanReceiverName - ? btoa(cleanReceiverName) - : window.deviceInfo.deviceId; - - if (data.maxBitrate) { - window.MaxBitrate = data.maxBitrate; - } - CommandHandler.processMessage(data, data.command); if (window.reportEventType) { @@ -424,10 +434,10 @@ export async function changeStream( params: any = undefined ): Promise { if ( - window.mediaManager.getMediaInformation().customData.canClientSeek && + window.playerManager.getMediaInformation().customData.canClientSeek && params == null ) { - window.mediaManager.seek(ticks / 10000000); + window.playerManager.seek(ticks / 10000000); reportPlaybackProgress($scope, getReportingParams($scope)); return Promise.resolve(); @@ -435,71 +445,31 @@ export async function changeStream( params = params || {}; - const playSessionId = $scope.playSessionId; - const liveStreamId = $scope.liveStreamId; - - const item = $scope.item; - const maxBitrate = await getMaxBitrate(); - - const deviceProfile = getDeviceProfile({ - bitrateSetting: maxBitrate, - enableHls: true + // TODO Could be useful for garbage collection. + // It needs to predict if the server side transcode needs + // to restart. + // Possibility: Always assume it will. Downside: VTT subs switching doesn't + // need to restart the transcode. + //const requiresStoppingTranscoding = false; + // + //if (requiresStoppingTranscoding) { + // window.playerManager.pause(); + // await stopActiveEncodings($scope.playSessionId); + //} + + return await PlaybackManager.playItemInternal($scope.item, { + audioStreamIndex: + params.AudioStreamIndex == null + ? $scope.audioStreamIndex + : params.AudioStreamIndex, + liveStreamId: $scope.liveStreamId, + mediaSourceId: $scope.mediaSourceId, + startPositionTicks: ticks, + subtitleStreamIndex: + params.SubtitleStreamIndex == null + ? $scope.subtitleStreamIndex + : params.SubtitleStreamIndex }); - const audioStreamIndex = - params.AudioStreamIndex == null - ? $scope.audioStreamIndex - : params.AudioStreamIndex; - const subtitleStreamIndex = - params.SubtitleStreamIndex == null - ? $scope.subtitleStreamIndex - : params.SubtitleStreamIndex; - - const playbackInformation = await getPlaybackInfo( - item, - maxBitrate, - deviceProfile, - ticks, - $scope.mediaSourceId, - audioStreamIndex, - subtitleStreamIndex, - liveStreamId - ); - - if (!validatePlaybackInfoResult(playbackInformation)) { - return; - } - - const mediaSource = playbackInformation.MediaSources[0]; - const streamInfo = createStreamInfo(item, mediaSource, ticks); - - if (!streamInfo.url) { - showPlaybackInfoErrorMessage('NoCompatibleStream'); - - return; - } - - const mediaInformation = createMediaInformation( - playSessionId, - item, - streamInfo - ); - const loadRequest = new cast.framework.messages.LoadRequestData(); - - loadRequest.media = mediaInformation; - loadRequest.autoplay = true; - - // TODO something to do with HLS? - const requiresStoppingTranscoding = false; - - if (requiresStoppingTranscoding) { - window.mediaManager.pause(); - await stopActiveEncodings(playSessionId); - } - - window.mediaManager.load(loadRequest); - window.mediaManager.play(); - $scope.subtitleStreamIndex = subtitleStreamIndex; - $scope.audioStreamIndex = audioStreamIndex; } // Create a message handler for the custome namespace channel @@ -550,10 +520,10 @@ export async function translateItems( if (method == 'PlayNext' || method == 'PlayLast') { for (let i = 0, length = options.items.length; i < length; i++) { - window.playlist.push(options.items[i]); + PlaybackManager.enqueue(options.items[i]); } } else { - playbackMgr.playFromOptions(data.options); + PlaybackManager.playFromOptions(data.options); } } @@ -570,7 +540,7 @@ export async function instantMix( const result = await getInstantMixItems(data.userId, item); options.items = result.Items; - playbackMgr.playFromOptions(data.options); + PlaybackManager.playFromOptions(data.options); } /** @@ -586,12 +556,16 @@ export async function shuffle( const result = await getShuffleItems(data.userId, item); options.items = result.Items; - playbackMgr.playFromOptions(data.options); + PlaybackManager.playFromOptions(data.options); } /** - * @param item - * @param options + * This function fetches the full information of an item before playing it. + * Only item.Id needs to be set. + * + * @param item - Item to look up + * @param options - Extra information about how it should be played back. + * @returns Promise waiting for the item to be loaded for playback */ export async function onStopPlayerBeforePlaybackDone( item: BaseItemDto, @@ -602,9 +576,7 @@ export async function onStopPlayerBeforePlaybackDone( type: 'GET' }); - // Attach the custom properties we created like userId, serverAddress, itemId, etc - extend(data, item); - playbackMgr.playItemInternal(data, options); + PlaybackManager.playItemInternal(data, options); broadcastConnectionErrorMessage(); } @@ -639,7 +611,7 @@ export async function getMaxBitrate(): Promise { lastBitrateDetect = new Date().getTime(); detectedBitrate = bitrate; - return detectedBitrate; + return Math.min(detectedBitrate, getMaxBitrateSupport()); } catch (e) { // The client can set this number console.log('Error detecting bitrate, will return device maximum.'); @@ -648,19 +620,6 @@ export async function getMaxBitrate(): Promise { } } -/** - * @param result - */ -export function validatePlaybackInfoResult(result: any): boolean { - if (result.ErrorCode) { - showPlaybackInfoErrorMessage(result.ErrorCode); - - return false; - } - - return true; -} - /** * @param error */ @@ -714,7 +673,7 @@ export function checkDirectPlay(mediaSource: MediaSourceInfo): void { */ export function setTextTrack(index: number | null): void { try { - const textTracksManager = window.mediaManager.getTextTracksManager(); + const textTracksManager = window.playerManager.getTextTracksManager(); if (index == null) { // docs: null is okay @@ -806,6 +765,7 @@ export function createMediaInformation( mediaInfo.contentId = streamInfo.url; mediaInfo.contentType = streamInfo.contentType; + // TODO make a type for this mediaInfo.customData = { audioStreamIndex: streamInfo.audioStreamIndex, canClientSeek: streamInfo.canClientSeek, @@ -831,7 +791,9 @@ export function createMediaInformation( ); } - mediaInfo.customData.startPositionTicks = streamInfo.startPosition || 0; + // If the client actually sets startPosition: + // if(streamInfo.startPosition) + // mediaInfo.customData.startPositionTicks = streamInfo.startPosition return mediaInfo; } @@ -870,7 +832,7 @@ if (!PRODUCTION) { // quits once the client closes the connection. // options.maxInactivity = 3600; - window.mediaManager.addEventListener( + window.playerManager.addEventListener( cast.framework.events.category.CORE, (event: framework.events.Event) => { console.log(`Core event: ${event.type}`); diff --git a/src/components/playbackManager.ts b/src/components/playbackManager.ts index f59b9786..208dce33 100644 --- a/src/components/playbackManager.ts +++ b/src/components/playbackManager.ts @@ -1,8 +1,6 @@ import { - getNextPlaybackItemInfo, getIntros, broadcastConnectionErrorMessage, - getReportingParams, createStreamInfo } from '../helpers'; @@ -10,10 +8,7 @@ import { getPlaybackInfo, getLiveStream, load, - reportPlaybackStart, - stop, - stopPingInterval, - reportPlaybackStopped + stopPingInterval } from './jellyfinActions'; import { getDeviceProfile } from './deviceprofileBuilder'; @@ -30,19 +25,18 @@ import { DocumentManager } from './documentManager'; import { BaseItemDto } from '~/api/generated/models/base-item-dto'; import { MediaSourceInfo } from '~/api/generated/models/media-source-info'; -export class playbackManager { - private playerManager: framework.PlayerManager; - // TODO remove any - private activePlaylist: Array; - private activePlaylistIndex: number; +import { ItemIndex } from '~/types/global'; - constructor(playerManager: framework.PlayerManager) { +export abstract class PlaybackManager { + private static playerManager: framework.PlayerManager; + private static activePlaylist: Array; + private static activePlaylistIndex: number; + + static setPlayerManager(playerManager: framework.PlayerManager): void { // Parameters this.playerManager = playerManager; - // Properties - this.activePlaylist = []; - this.activePlaylistIndex = 0; + this.resetPlaylist(); } /* This is used to check if we can switch to @@ -51,7 +45,7 @@ export class playbackManager { * Returns true when playing or paused. * (before: true only when playing) * */ - isPlaying(): boolean { + static isPlaying(): boolean { return ( this.playerManager.getPlayerState() === cast.framework.messages.PlayerState.PLAYING || @@ -60,7 +54,7 @@ export class playbackManager { ); } - async playFromOptions(options: any): Promise { + static async playFromOptions(options: any): Promise { const firstItem = options.items[0]; if (options.startPositionTicks || firstItem.MediaType !== 'Video') { @@ -74,26 +68,47 @@ export class playbackManager { return this.playFromOptionsInternal(options); } - playFromOptionsInternal(options: any): boolean { + private static playFromOptionsInternal(options: any): Promise { const stopPlayer = this.activePlaylist && this.activePlaylist.length > 0; this.activePlaylist = options.items; - window.currentPlaylistIndex = -1; - window.playlist = this.activePlaylist; + this.activePlaylistIndex = options.startIndex || 0; + + console.log('Loaded new playlist:', this.activePlaylist); + + // When starting playback initially, don't use + // the next item facility. + return this.playItem(options, stopPlayer); + } + + // add item to playlist + static enqueue(item: BaseItemDto): void { + this.activePlaylist.push(item); + } + + static resetPlaylist(): void { + this.activePlaylistIndex = -1; + this.activePlaylist = []; + } - return this.playNextItem(options, stopPlayer); + // If there are items in the queue after the current one + static hasNextItem(): boolean { + return this.activePlaylistIndex < this.activePlaylist.length - 1; } - playNextItem(options: any = {}, stopPlayer = false): boolean { - const nextItemInfo = getNextPlaybackItemInfo(); + // If there are items in the queue before the current one + static hasPrevItem(): boolean { + return this.activePlaylistIndex > 0; + } + + static playNextItem(options: any = {}, stopPlayer = false): boolean { + const nextItemInfo = this.getNextPlaybackItemInfo(); if (nextItemInfo) { this.activePlaylistIndex = nextItemInfo.index; - const item = nextItemInfo.item; - - this.playItem(item, options, stopPlayer); + this.playItem(options, stopPlayer); return true; } @@ -101,13 +116,11 @@ export class playbackManager { return false; } - playPreviousItem(options: any = {}): boolean { + static playPreviousItem(options: any = {}): boolean { if (this.activePlaylist && this.activePlaylistIndex > 0) { this.activePlaylistIndex--; - const item = this.activePlaylist[this.activePlaylistIndex]; - - this.playItem(item, options, true); + this.playItem(options, true); return true; } @@ -115,19 +128,27 @@ export class playbackManager { return false; } - async playItem( - item: BaseItemDto, + // play item from playlist + private static async playItem( options: any, stopPlayer = false ): Promise { if (stopPlayer) { - await this.stop(true); + this.stop(); } + const item = this.activePlaylist[this.activePlaylistIndex]; + + console.log(`Playing index ${this.activePlaylistIndex}`, item); + return await onStopPlayerBeforePlaybackDone(item, options); } - async playItemInternal(item: BaseItemDto, options: any): Promise { + // Would set private, but some refactorings need to happen first. + static async playItemInternal( + item: BaseItemDto, + options: any + ): Promise { $scope.isChangingStream = false; DocumentManager.setAppStatus('loading'); @@ -143,7 +164,8 @@ export class playbackManager { options.startPositionTicks, options.mediaSourceId, options.audioStreamIndex, - options.subtitleStreamIndex + options.subtitleStreamIndex, + options.liveStreamId ).catch(broadcastConnectionErrorMessage); if (playbackInfo.ErrorCode) { @@ -186,8 +208,7 @@ export class playbackManager { ); } - // TODO eradicate any - playMediaSource( + private static playMediaSource( playSessionId: string, item: BaseItemDto, mediaSource: MediaSourceInfo, @@ -201,8 +222,6 @@ export class playbackManager { options.startPositionTicks ); - const url = streamInfo.url; - const mediaInfo = createMediaInformation( playSessionId, item, @@ -213,46 +232,98 @@ export class playbackManager { loadRequestData.media = mediaInfo; loadRequestData.autoplay = true; + // If we should seek at the start, translate it + // to seconds and give it to loadRequestData :) + if (mediaInfo.customData.startPositionTicks > 0) { + loadRequestData.currentTime = + mediaInfo.customData.startPositionTicks / 10000000; + } + load($scope, mediaInfo.customData, item); this.playerManager.load(loadRequestData); + console.log(`setting src to ${streamInfo.url}`); $scope.PlaybackMediaSource = mediaSource; - console.log(`setting src to ${url}`); $scope.mediaSource = mediaSource; + $scope.audioStreamIndex = streamInfo.audioStreamIndex; + $scope.subtitleStreamIndex = streamInfo.subtitleStreamIndex; DocumentManager.setPlayerBackdrop(item); - reportPlaybackStart($scope, getReportingParams($scope)); - // We use false as we do not want to broadcast the new status yet // we will broadcast manually when the media has been loaded, this // is to be sure the duration has been updated in the media element this.playerManager.setMediaInformation(mediaInfo, false); } - stop(continuing = false): Promise { - $scope.playNextItem = continuing; - stop(); + /** + * stop playback, as requested by the client + */ + static stop(): void { + this.playerManager.stop(); + // onStopped will be called when playback comes to a halt. + } - const reportingParams = getReportingParams($scope); + /** + * Called when media stops playing. + * TODO avoid doing this between tracks in a playlist + */ + static onStopped(): void { + if (this.getNextPlaybackItemInfo()) { + $scope.playNextItem = true; + } else { + $scope.playNextItem = false; - let promise; + DocumentManager.setAppStatus('waiting'); - stopPingInterval(); + stopPingInterval(); - if (reportingParams.ItemId) { - promise = reportPlaybackStopped($scope, reportingParams); + DocumentManager.startBackdropInterval(); } + } - this.playerManager.stop(); + /** + * Get information about the next item to play from window.playlist + * + * @returns item and index, or null to end playback + */ + static getNextPlaybackItemInfo(): ItemIndex | null { + if (this.activePlaylist.length < 1) { + return null; + } - this.activePlaylist = []; - this.activePlaylistIndex = -1; - DocumentManager.startBackdropInterval(); + let newIndex: number; + + if (this.activePlaylistIndex < 0) { + // negative = play the first item + newIndex = 0; + } else { + switch (window.repeatMode) { + case 'RepeatOne': + newIndex = this.activePlaylistIndex; + break; + case 'RepeatAll': + newIndex = this.activePlaylistIndex + 1; + + if (newIndex >= this.activePlaylist.length) { + newIndex = 0; + } + + break; + default: + newIndex = this.activePlaylistIndex + 1; + break; + } + } - promise = promise || Promise.resolve(); + if (newIndex < this.activePlaylist.length) { + return { + index: newIndex, + item: this.activePlaylist[newIndex] + }; + } - return promise; + return null; } } diff --git a/src/helpers.ts b/src/helpers.ts index 962433bd..9f5f14e0 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -1,5 +1,6 @@ import { JellyfinApi } from './components/jellyfinApi'; import { DocumentManager } from './components/documentManager'; +import { PlaybackManager } from './components/playbackManager'; import { BaseItemDtoQueryResult } from './api/generated/models/base-item-dto-query-result'; import { PlaybackProgressInfo } from './api/generated/models/playback-progress-info'; @@ -7,7 +8,7 @@ import { MediaSourceInfo } from './api/generated/models/media-source-info'; import { BaseItemDto } from './api/generated/models/base-item-dto'; import { BaseItemPerson } from './api/generated/models/base-item-person'; import { UserDto } from './api/generated/models/user-dto'; -import { GlobalScope, BusMessage, ItemIndex, ItemQuery } from './types/global'; +import { GlobalScope, BusMessage, ItemQuery } from './types/global'; /** * Get current playback position in ticks, adjusted for server seeking @@ -16,8 +17,8 @@ import { GlobalScope, BusMessage, ItemIndex, ItemQuery } from './types/global'; * @returns position in ticks */ export function getCurrentPositionTicks($scope: GlobalScope): number { - let positionTicks = window.mediaManager.getCurrentTimeSec() * 10000000; - const mediaInformation = window.mediaManager.getMediaInformation(); + let positionTicks = window.playerManager.getCurrentTimeSec() * 10000000; + const mediaInformation = window.playerManager.getMediaInformation(); if (mediaInformation && !mediaInformation.customData.canClientSeek) { positionTicks += $scope.startPositionTicks || 0; @@ -43,7 +44,7 @@ export function getReportingParams($scope: GlobalScope): PlaybackProgressInfo { CanSeek: $scope.canSeek, IsMuted: window.volume.muted, IsPaused: - window.mediaManager.getPlayerState() === + window.playerManager.getPlayerState() === cast.framework.messages.PlayerState.PAUSED, ItemId: $scope.itemId, LiveStreamId: $scope.liveStreamId, @@ -57,53 +58,6 @@ export function getReportingParams($scope: GlobalScope): PlaybackProgressInfo { }; } -/** - * Get information about the next item to play from window.playlist - * - * @returns ItemIndex including item and index, or null to end playback - */ -export function getNextPlaybackItemInfo(): ItemIndex | null { - const playlist = window.playlist; - - if (!playlist) { - return null; - } - - let newIndex: number; - - if (window.currentPlaylistIndex == -1) { - newIndex = 0; - } else { - switch (window.repeatMode) { - case 'RepeatOne': - newIndex = window.currentPlaylistIndex; - break; - case 'RepeatAll': - newIndex = window.currentPlaylistIndex + 1; - - if (newIndex >= window.playlist.length) { - newIndex = 0; - } - - break; - default: - newIndex = window.currentPlaylistIndex + 1; - break; - } - } - - if (newIndex < playlist.length) { - const item = playlist[newIndex]; - - return { - index: newIndex, - item: item - }; - } - - return null; -} - /** * This is used in playback reporting to find out information * about the item that is currently playing. This is sent over the cast protocol over to @@ -196,7 +150,7 @@ export function getSenderReportingData( } if ($scope.playNextItem) { - const nextItemInfo = getNextPlaybackItemInfo(); + const nextItemInfo = PlaybackManager.getNextPlaybackItemInfo(); if (nextItemInfo) { state.NextMediaType = nextItemInfo.item.MediaType; @@ -228,7 +182,6 @@ export function resetPlaybackScope($scope: GlobalScope): void { $scope.playMethod = ''; $scope.canSeek = false; - $scope.canClientSeek = false; $scope.isChangingStream = false; $scope.playNextItem = true; @@ -811,23 +764,6 @@ export async function translateRequestedItems( }; } -/** - * Take all properties of source and copy them over to target - * - * TODO can we remove this crap - * - * @param target - object that gets populated with entries - * @param source - object that the entries are copied from - * @returns reference to target object - */ -export function extend(target: any, source: any): any { - for (const i in source) { - target[i] = source[i]; - } - - return target; -} - /** * Parse a date.. Just a wrapper around new Date, * but could be useful to deal with weird date strings @@ -859,13 +795,3 @@ export function broadcastToMessageBus(message: BusMessage): void { export function broadcastConnectionErrorMessage(): void { broadcastToMessageBus({ message: '', type: 'connectionerror' }); } - -/** - * Remove all special characters from a string - * - * @param name - input string - * @returns string with non-whitespace non-word characters removed - */ -export function cleanName(name: string): string { - return name.replace(/[^\w\s]/gi, ''); -} diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 2018cfb8..0570ce93 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -6,12 +6,6 @@ import { SystemVolumeData } from 'chromecast-caf-receiver/cast.framework.system' import { RepeatMode } from '../api/generated/models/repeat-mode'; import { BaseItemDto } from '../api/generated/models/base-item-dto'; -export interface DeviceInfo { - deviceId: string | number; - deviceName: string; - versionNumber: string; -} - export interface GlobalScope { [key: string]: any; } @@ -102,12 +96,9 @@ declare global { export const RECEIVERVERSION: string; export const $scope: GlobalScope; export interface Window { - deviceInfo: DeviceInfo; mediaElement: HTMLElement | null; - mediaManager: PlayerManager; + playerManager: PlayerManager; castReceiverContext: CastReceiverContext; - playlist: Array; - currentPlaylistIndex: number; repeatMode: RepeatMode; reportEventType: 'repeatmodechange'; subtitleAppearance: any;