diff --git a/src/app.ts b/src/app.ts index f6f2a9a7..b4e34ce1 100644 --- a/src/app.ts +++ b/src/app.ts @@ -6,6 +6,4 @@ import './css/jellyfin.css'; window.mediaElement = document.getElementById('video-player'); -window.playlist = []; -window.currentPlaylistIndex = -1; window.repeatMode = RepeatMode.RepeatNone; diff --git a/src/components/__tests__/jellyfinApi.test.ts b/src/components/__tests__/jellyfinApi.test.ts index d4ef9d12..ed8e3989 100644 --- a/src/components/__tests__/jellyfinApi.test.ts +++ b/src/components/__tests__/jellyfinApi.test.ts @@ -1,8 +1,8 @@ import { JellyfinApi } from '../jellyfinApi'; const setupMockCastSenders = (): void => { - const getSenders = (): Array => [{ id: 'thisIsSenderId' }]; - const getInstance = (): any => ({ getSenders }); + const getSenders = (): Array => [{ id: 'thisIsSenderId' }]; // eslint-disable-line @typescript-eslint/no-explicit-any + const getInstance = (): any => ({ getSenders }); // eslint-disable-line @typescript-eslint/no-explicit-any // @ts-expect-error cast is already defined globally, however since we're mocking it we need to redefine it. global.cast = { diff --git a/src/components/codecSupportHelper.ts b/src/components/codecSupportHelper.ts index 214732ed..0845050f 100644 --- a/src/components/codecSupportHelper.ts +++ b/src/components/codecSupportHelper.ts @@ -93,9 +93,20 @@ export function getMaxBitrateSupport(): number { /** * Get the max supported video width the active Cast device supports. * @param deviceId - Cast device id. + * @param codec - Video codec. * @returns Max supported width. */ -export function getMaxWidthSupport(deviceId: number): number { +export function getMaxWidthSupport(deviceId: number, codec?: string): number { + if (codec === 'h264') { + // 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: diff --git a/src/components/commandHandler.ts b/src/components/commandHandler.ts index 026dfe3e..4e44a0a9 100644 --- a/src/components/commandHandler.ts +++ b/src/components/commandHandler.ts @@ -1,5 +1,5 @@ -import { getReportingParams } from '../helpers'; -import { +import { getReportingParams, TicksPerSecond } from '../helpers'; +import type { DataMessage, DisplayRequest, PlayRequest, @@ -8,6 +8,7 @@ import { SetRepeatModeRequest, SupportedCommands } from '../types/global'; +import { AppStatus } from '../types/appStatus'; import { translateItems, shuffle, @@ -16,16 +17,12 @@ import { setSubtitleStreamIndex, seek } from './maincontroller'; - 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 +49,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,36 +82,33 @@ 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({}); } } static setAudioStreamIndexHandler(data: DataMessage): void { setAudioStreamIndex( - this.playbackManager.playbackState, + PlaybackManager.playbackState, (data.options).index ); } static setSubtitleStreamIndexHandler(data: DataMessage): void { setSubtitleStreamIndex( - this.playbackManager.playbackState, + PlaybackManager.playbackState, (data.options).index ); } @@ -144,13 +134,17 @@ export abstract class CommandHandler { } static IdentifyHandler(): void { - if (!this.playbackManager.isPlaying()) { + if (!PlaybackManager.isPlaying()) { + if (!PlaybackManager.isBuffering()) { + DocumentManager.setAppStatus(AppStatus.Waiting); + } + DocumentManager.startBackdropInterval(); } else { // When a client connects send back the initial device state (volume etc) via a playbackstop message reportPlaybackProgress( - this.playbackManager.playbackState, - getReportingParams(this.playbackManager.playbackState), + PlaybackManager.playbackState, + getReportingParams(PlaybackManager.playbackState), true, 'playbackstop' ); @@ -159,8 +153,8 @@ export abstract class CommandHandler { static SeekHandler(data: DataMessage): void { seek( - this.playbackManager.playbackState, - (data.options).position * 10000000 + PlaybackManager.playbackState, + (data.options).position * TicksPerSecond ); } diff --git a/src/components/deviceprofileBuilder.ts b/src/components/deviceprofileBuilder.ts index 2c801e9e..91c33065 100644 --- a/src/components/deviceprofileBuilder.ts +++ b/src/components/deviceprofileBuilder.ts @@ -45,6 +45,7 @@ let profileOptions: ProfileOptions; let currentDeviceId: number; /** + * Create and return a new ProfileCondition * @param Property - What property the condition should test. * @param Condition - The condition to test the values for. * @param Value - The value to compare against. @@ -66,6 +67,8 @@ function createProfileCondition( } /** + * Get container profiles + * @todo Why does this always return an empty array? * @returns Container profiles. */ function getContainerProfiles(): Array { @@ -73,6 +76,7 @@ function getContainerProfiles(): Array { } /** + * Get response profiles * @returns Response profiles. */ function getResponseProfiles(): Array { @@ -87,6 +91,7 @@ function getResponseProfiles(): Array { } /** + * Get direct play profiles * @returns Direct play profiles. */ function getDirectPlayProfiles(): Array { @@ -150,6 +155,7 @@ function getDirectPlayProfiles(): Array { } /** + * Get codec profiles * @returns Codec profiles. */ function getCodecProfiles(): Array { @@ -224,7 +230,7 @@ function getCodecProfiles(): Array { createProfileCondition( ProfileConditionValue.Width, ProfileConditionType.LessThanEqual, - maxWidth.toString(), + getMaxWidthSupport(currentDeviceId, 'h264').toString(), true ) ], @@ -271,7 +277,7 @@ function getCodecProfiles(): Array { createProfileCondition( ProfileConditionValue.Width, ProfileConditionType.LessThanEqual, - maxWidth.toString(), + getMaxWidthSupport(currentDeviceId).toString(), true ) ], @@ -297,6 +303,7 @@ function getCodecProfiles(): Array { } /** + * Get transcoding profiles * @returns Transcoding profiles. */ function getTranscodingProfiles(): Array { @@ -375,6 +382,7 @@ function getTranscodingProfiles(): Array { } /** + * Get subtitle profiles * @returns Subtitle profiles. */ function getSubtitleProfiles(): Array { diff --git a/src/components/documentManager.ts b/src/components/documentManager.ts index 839694e2..f19d2e12 100644 --- a/src/components/documentManager.ts +++ b/src/components/documentManager.ts @@ -1,6 +1,6 @@ import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'; - -import { parseISO8601Date } from '../helpers'; +import { AppStatus } from '../types/appStatus'; +import { parseISO8601Date, TicksPerSecond, ticksToSeconds } from '../helpers'; import { JellyfinApi } from './jellyfinApi'; import { deviceIds, getActiveDeviceId } from './castDevices'; @@ -8,10 +8,9 @@ export abstract class DocumentManager { // Duration between each backdrop switch in ms private static backdropPeriodMs: number | null = 30000; // Timer state - so that we don't start the interval more than necessary - private static backdropTimer = 0; + private static backdropTimer: NodeJS.Timer | null = null; - // TODO make enum - private static status = ''; + private static status = AppStatus.Unset; /** * Hide the document body on chromecast audio to save resources @@ -181,7 +180,7 @@ export abstract class DocumentManager { } // Switch visible view! - this.setAppStatus('details'); + this.setAppStatus(AppStatus.Details); }); } @@ -277,7 +276,7 @@ export abstract class DocumentManager { * to the corresponding one. * @param status - to set */ - public static setAppStatus(status: string): void { + public static setAppStatus(status: AppStatus): void { this.status = status; document.body.className = status; } @@ -286,7 +285,7 @@ export abstract class DocumentManager { * Get the status of the app * @returns app status */ - public static getAppStatus(): string { + public static getAppStatus(): AppStatus { return this.status; } @@ -390,9 +389,9 @@ export abstract class DocumentManager { * Stop the backdrop rotation */ public static clearBackdropInterval(): void { - if (this.backdropTimer !== 0) { + if (this.backdropTimer !== null) { clearInterval(this.backdropTimer); - this.backdropTimer = 0; + this.backdropTimer = null; } } @@ -416,11 +415,9 @@ export abstract class DocumentManager { return; } - this.backdropTimer = ( - setInterval( - () => DocumentManager.setRandomUserBackdrop(), - this.backdropPeriodMs - ) + this.backdropTimer = setInterval( + () => DocumentManager.setRandomUserBackdrop(), + this.backdropPeriodMs ); await this.setRandomUserBackdrop(); @@ -435,7 +432,7 @@ export abstract class DocumentManager { this.backdropPeriodMs = period; // If the timer was running, restart it - if (this.backdropTimer !== 0) { + if (this.backdropTimer !== null) { // startBackdropInterval will also clear the previous one this.startBackdropInterval(); } @@ -605,9 +602,8 @@ export abstract class DocumentManager { * @returns human readable position */ private static formatRunningTime(ticks: number): string { - const ticksPerHour = 36000000000; - const ticksPerMinute = 600000000; - const ticksPerSecond = 10000000; + const ticksPerMinute = TicksPerSecond * 60; + const ticksPerHour = ticksPerMinute * 60; const parts: string[] = []; @@ -629,7 +625,7 @@ export abstract class DocumentManager { parts.push(minutes.toString()); } - const seconds: number = Math.floor(ticks / ticksPerSecond); + const seconds: number = Math.floor(ticksToSeconds(ticks)); if (seconds < 10) { parts.push(`0${seconds.toString()}`); diff --git a/src/components/fetchhelper.ts b/src/components/fetchhelper.ts index 582fb372..c9ec5e9b 100644 --- a/src/components/fetchhelper.ts +++ b/src/components/fetchhelper.ts @@ -3,6 +3,7 @@ * @param request - Custom request object, mostly modeled after RequestInit. * @returns response promise */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any function getFetchPromise(request: any): Promise { const headers = request.headers || {}; @@ -102,6 +103,7 @@ function paramsToString(params: Record): string { * @param request - RequestInit-like structure but with url/type/timeout parameters as well * @returns response promise, may be automatically unpacked based on request datatype */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any export async function ajax(request: any): Promise { if (!request) { throw new Error('Request cannot be null'); diff --git a/src/components/jellyfinActions.ts b/src/components/jellyfinActions.ts index ecd384e4..b5ffcbd9 100644 --- a/src/components/jellyfinActions.ts +++ b/src/components/jellyfinActions.ts @@ -6,11 +6,11 @@ import type { PlaybackProgressInfo, PlayRequest } from '@jellyfin/sdk/lib/generated-client'; - import { getSenderReportingData, broadcastToMessageBus } from '../helpers'; +import { AppStatus } from '../types/appStatus'; import { JellyfinApi } from './jellyfinApi'; import { DocumentManager } from './documentManager'; -import { playbackManager, PlaybackState } from './playbackManager'; +import { PlaybackManager, PlaybackState } from './playbackManager'; interface PlayRequestQuery extends PlayRequest { UserId?: string; @@ -34,6 +34,7 @@ function restartPingInterval(reportingParams: PlaybackProgressInfo): void { stopPingInterval(); if (reportingParams.PlayMethod == 'Transcode') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any pingInterval = setInterval(() => { pingTranscoder(reportingParams); }, 1000); @@ -180,18 +181,14 @@ export function pingTranscoder( /** * Update the context about the item we are playing. - * @param playbackMgr - playback manager. * @param customData - data to set on playback state. * @param serverItem - item that is playing */ -export function load( - playbackMgr: playbackManager, - customData: any, - serverItem: BaseItemDto -): void { - playbackMgr.resetPlaybackScope(); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function load(customData: any, serverItem: BaseItemDto): void { + PlaybackManager.resetPlaybackScope(); - const state = playbackMgr.playbackState; + const state = PlaybackManager.playbackState; // These are set up in maincontroller.createMediaInformation state.playSessionId = customData.playSessionId; @@ -207,7 +204,7 @@ export function load( state.item = serverItem; - DocumentManager.setAppStatus('backdrop'); + DocumentManager.setAppStatus(AppStatus.Backdrop); state.mediaType = serverItem?.MediaType; } @@ -222,41 +219,34 @@ export function load( */ export function play(state: PlaybackState): void { if ( - DocumentManager.getAppStatus() == 'backdrop' || - DocumentManager.getAppStatus() == 'playing-with-controls' || - DocumentManager.getAppStatus() == 'playing' || - DocumentManager.getAppStatus() == 'audio' + DocumentManager.getAppStatus() == AppStatus.Backdrop || + DocumentManager.getAppStatus() == AppStatus.PlayingWithControls || + DocumentManager.getAppStatus() == AppStatus.Playing || + DocumentManager.getAppStatus() == AppStatus.Audio ) { setTimeout(() => { window.playerManager.play(); if (state.mediaType == 'Audio') { - DocumentManager.setAppStatus('audio'); + DocumentManager.setAppStatus(AppStatus.Audio); } else { - DocumentManager.setAppStatus('playing-with-controls'); + DocumentManager.setAppStatus(AppStatus.PlayingWithControls); } }, 20); } } /** - * Don't actually stop, just show the idle view after 20ms - */ -export function stop(): void { - setTimeout(() => { - DocumentManager.setAppStatus('waiting'); - }, 20); -} - -/** - * @param item - * @param maxBitrate - * @param deviceProfile - * @param startPosition - * @param mediaSourceId - * @param audioStreamIndex - * @param subtitleStreamIndex - * @param liveStreamId + * get PlaybackInfo + * @param item - item + * @param maxBitrate - maxBitrate + * @param deviceProfile - deviceProfile + * @param startPosition - startPosition + * @param mediaSourceId - mediaSourceId + * @param audioStreamIndex - audioStreamIndex + * @param subtitleStreamIndex - subtitleStreamIndex + * @param liveStreamId - liveStreamId + * @returns promise */ export function getPlaybackInfo( item: BaseItemDto, @@ -267,6 +257,7 @@ export function getPlaybackInfo( audioStreamIndex: number, subtitleStreamIndex: number, liveStreamId: string | null = null + // eslint-disable-next-line @typescript-eslint/no-explicit-any ): Promise { const postData = { DeviceProfile: deviceProfile @@ -305,14 +296,16 @@ export function getPlaybackInfo( } /** - * @param item - * @param playSessionId - * @param maxBitrate - * @param deviceProfile - * @param startPosition - * @param mediaSource - * @param audioStreamIndex - * @param subtitleStreamIndex + * get LiveStream + * @param item - item + * @param playSessionId - playSessionId + * @param maxBitrate - maxBitrate + * @param deviceProfile - deviceProfile + * @param startPosition - startPosition + * @param mediaSource - mediaSource + * @param audioStreamIndex - audioStreamIndex + * @param subtitleStreamIndex - subtitleStreamIndex + * @returns promise */ export function getLiveStream( item: BaseItemDto, @@ -356,7 +349,6 @@ export function getLiveStream( /** * Get download speed based on the jellyfin bitratetest api. - * * The API has a 10MB limit. * @param byteSize - number of bytes to request * @returns the bitrate in bits/s @@ -366,11 +358,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); @@ -380,21 +375,28 @@ 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 - - let bitrate = await getDownloadSpeed(1000000); +export async function detectBitrate(numBytes = 500000): Promise { + // Jellyfin has a 10MB limit on the test size + const byteLimit = 10000000; - 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); + } } /** diff --git a/src/components/jellyfinApi.ts b/src/components/jellyfinApi.ts index 43e47da7..9c016938 100644 --- a/src/components/jellyfinApi.ts +++ b/src/components/jellyfinApi.ts @@ -26,14 +26,16 @@ export abstract class JellyfinApi { receiverName = '' ): void { console.debug( - `JellyfinApi.setServerInfo: user:${userId}, token:${accessToken}, ` + - `server:${serverAddress}, name:${receiverName}` + `JellyfinApi.setServerInfo: user:${userId}, token:${accessToken}, server:${serverAddress}, name:${receiverName}` ); this.userId = userId; this.accessToken = accessToken; this.serverAddress = serverAddress; if (receiverName) { + // remove special characters from the receiver name + receiverName = receiverName.replace(/[^\w\s]/gi, ''); + this.deviceName = receiverName; // deviceId just needs to be unique-ish this.deviceId = btoa(receiverName); @@ -136,6 +138,7 @@ export abstract class JellyfinApi { } // Authenticated ajax + // eslint-disable-next-line @typescript-eslint/no-explicit-any public static authAjax(path: string, args: any): Promise { if ( this.userId === undefined || @@ -158,6 +161,7 @@ export abstract class JellyfinApi { } // Authenticated ajax + // eslint-disable-next-line @typescript-eslint/no-explicit-any public static authAjaxUser(path: string, args: any): Promise { if ( this.userId === undefined || diff --git a/src/components/maincontroller.ts b/src/components/maincontroller.ts index 3df3da81..5aac5e0a 100644 --- a/src/components/maincontroller.ts +++ b/src/components/maincontroller.ts @@ -2,59 +2,56 @@ import type { BaseItemDto, MediaSourceInfo } from '@jellyfin/sdk/lib/generated-client'; - import { getCurrentPositionTicks, getReportingParams, getMetadata, - createStreamInfo, getStreamByIndex, getShuffleItems, getInstantMixItems, translateRequestedItems, - broadcastToMessageBus + broadcastToMessageBus, + ticksToSeconds } from '../helpers'; import { + reportPlaybackStart, reportPlaybackProgress, reportPlaybackStopped, play, - getPlaybackInfo, - stopActiveEncodings, detectBitrate } from './jellyfinActions'; import { getDeviceProfile } from './deviceprofileBuilder'; import { JellyfinApi } from './jellyfinApi'; -import { playbackManager, PlaybackState } from './playbackManager'; +import { PlaybackManager, PlaybackState } from './playbackManager'; import { CommandHandler } from './commandHandler'; import { getMaxBitrateSupport } from './codecSupportHelper'; -import { DocumentManager } from './documentManager'; import { PlayRequest } from '~/types/global'; window.castReceiverContext = cast.framework.CastReceiverContext.getInstance(); window.playerManager = window.castReceiverContext.getPlayerManager(); -const playbackMgr = new playbackManager(window.playerManager); +PlaybackManager.setPlayerManager(window.playerManager); -CommandHandler.configure(window.playerManager, playbackMgr); +CommandHandler.configure(window.playerManager); -playbackMgr.resetPlaybackScope(); +PlaybackManager.resetPlaybackScope(); let broadcastToServer = new Date(); let hasReportedCapabilities = false; /** - * + * onMediaElementTimeUpdate */ export function onMediaElementTimeUpdate(): void { - if (playbackMgr.playbackState.isChangingStream) { + if (PlaybackManager.playbackState.isChangingStream) { return; } const now = new Date(); const elapsed = now.valueOf() - broadcastToServer.valueOf(); - const playbackState = playbackMgr.playbackState; + const playbackState = PlaybackManager.playbackState; if (elapsed > 5000) { // TODO use status as input @@ -74,10 +71,10 @@ export function onMediaElementTimeUpdate(): void { } /** - * + * onMediaElementPause */ export function onMediaElementPause(): void { - if (playbackMgr.playbackState.isChangingStream) { + if (PlaybackManager.playbackState.isChangingStream) { return; } @@ -85,10 +82,10 @@ export function onMediaElementPause(): void { } /** - * + * onMediaElementPlaying */ export function onMediaElementPlaying(): void { - if (playbackMgr.playbackState.isChangingStream) { + if (PlaybackManager.playbackState.isChangingStream) { return; } @@ -96,7 +93,8 @@ export function onMediaElementPlaying(): void { } /** - * @param event + * onMediaElementVolumeChange + * @param event - event */ function onMediaElementVolumeChange(event: framework.system.Event): void { window.volume = (event).data; @@ -108,7 +106,7 @@ function onMediaElementVolumeChange(event: framework.system.Event): void { } /** - * + * enableTimeUpdateListener */ export function enableTimeUpdateListener(): void { window.playerManager.addEventListener( @@ -130,7 +128,7 @@ export function enableTimeUpdateListener(): void { } /** - * + * disableTimeUpdateListener */ export function disableTimeUpdateListener(): void { window.playerManager.removeEventListener( @@ -155,7 +153,7 @@ enableTimeUpdateListener(); window.addEventListener('beforeunload', () => { // Try to cleanup after ourselves before the page closes - const playbackState = playbackMgr.playbackState; + const playbackState = PlaybackManager.playbackState; disableTimeUpdateListener(); reportPlaybackStopped(playbackState, getReportingParams(playbackState)); @@ -164,7 +162,7 @@ window.addEventListener('beforeunload', () => { window.playerManager.addEventListener( cast.framework.events.EventType.PLAY, (): void => { - const playbackState = playbackMgr.playbackState; + const playbackState = PlaybackManager.playbackState; play(playbackState); reportPlaybackProgress( @@ -177,7 +175,7 @@ window.playerManager.addEventListener( window.playerManager.addEventListener( cast.framework.events.EventType.PAUSE, (): void => { - const playbackState = playbackMgr.playbackState; + const playbackState = PlaybackManager.playbackState; reportPlaybackProgress( playbackState, @@ -187,10 +185,10 @@ window.playerManager.addEventListener( ); /** - * + * defaultOnStop */ function defaultOnStop(): void { - playbackMgr.stop(); + PlaybackManager.onStop(); } window.playerManager.addEventListener( @@ -205,7 +203,7 @@ window.playerManager.addEventListener( window.playerManager.addEventListener( cast.framework.events.EventType.ENDED, (): void => { - const playbackState = playbackMgr.playbackState; + const playbackState = PlaybackManager.playbackState; // If we're changing streams, do not report playback ended. if (playbackState.isChangingStream) { @@ -213,16 +211,36 @@ window.playerManager.addEventListener( } reportPlaybackStopped(playbackState, getReportingParams(playbackState)); - playbackMgr.resetPlaybackScope(); + PlaybackManager.resetPlaybackScope(); - if (!playbackMgr.playNextItem()) { - window.playlist = []; - window.currentPlaylistIndex = -1; - DocumentManager.startBackdropInterval(); + if (!PlaybackManager.playNextItem()) { + PlaybackManager.resetPlaylist(); + PlaybackManager.onStop(); } } ); +// 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( + PlaybackManager.playbackState, + getReportingParams(PlaybackManager.playbackState) + ); + } +); +// 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( + PlaybackManager.playbackState, + getReportingParams(PlaybackManager.playbackState) + ); + } +); + // Set the active subtitle track once the player has loaded window.playerManager.addEventListener( cast.framework.events.EventType.PLAYER_LOAD_COMPLETE, @@ -235,7 +253,8 @@ window.playerManager.addEventListener( ); /** - * + * reportDeviceCapabilities + * @returns Promise */ export async function reportDeviceCapabilities(): Promise { const maxBitrate = await getMaxBitrate(); @@ -262,8 +281,10 @@ export async function reportDeviceCapabilities(): Promise { } /** - * @param data + * processMessage + * @param data - data */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any export function processMessage(data: any): void { if ( !data.command || @@ -309,7 +330,7 @@ export function processMessage(data: any): void { CommandHandler.processMessage(data, data.command); if (window.reportEventType) { - const playbackState = playbackMgr.playbackState; + const playbackState = PlaybackManager.playbackState; const report = (): void => { reportPlaybackProgress( @@ -331,14 +352,16 @@ export function processMessage(data: any): void { } /** - * @param name - * @param reportToServer + * reportEvent + * @param name - name + * @param reportToServer - reportToServer + * @returns Promise */ export function reportEvent( name: string, reportToServer: boolean ): Promise { - const playbackState = playbackMgr.playbackState; + const playbackState = PlaybackManager.playbackState; return reportPlaybackProgress( playbackState, @@ -349,8 +372,9 @@ export function reportEvent( } /** + * setSubtitleStreamIndex * @param state - playback state. - * @param index + * @param index - index */ export function setSubtitleStreamIndex( state: PlaybackState, @@ -362,6 +386,7 @@ export function setSubtitleStreamIndex( // FIXME: Possible index error when MediaStreams is undefined. const currentSubtitleStream = state.mediaSource?.MediaStreams?.filter( + // eslint-disable-next-line @typescript-eslint/no-explicit-any (m: any) => { return m.Index == state.subtitleStreamIndex && m.Type == 'Subtitle'; } @@ -390,7 +415,7 @@ export function setSubtitleStreamIndex( const mediaStreams = state.PlaybackMediaSource?.MediaStreams; const subtitleStream = getStreamByIndex( - mediaStreams, + mediaStreams, // eslint-disable-line @typescript-eslint/no-explicit-any 'Subtitle', index ); @@ -430,8 +455,10 @@ export function setSubtitleStreamIndex( } /** + * setAudioStreamIndex * @param state - playback state. - * @param index + * @param index - index + * @returns promise */ export function setAudioStreamIndex( state: PlaybackState, @@ -445,28 +472,32 @@ export function setAudioStreamIndex( } /** + * seek * @param state - playback state. - * @param ticks + * @param ticks - ticks + * @returns promise */ export function seek(state: PlaybackState, ticks: number): Promise { return changeStream(state, ticks); } /** + * changeStream * @param state - playback state. - * @param ticks - * @param params + * @param ticks - ticks + * @param params - params + * @returns promise */ export async function changeStream( state: PlaybackState, ticks: number, - params: any = undefined + params: any = undefined // eslint-disable-line @typescript-eslint/no-explicit-any ): Promise { if ( window.playerManager.getMediaInformation()?.customData.canClientSeek && params == null ) { - window.playerManager.seek(ticks / 10000000); + window.playerManager.seek(ticksToSeconds(ticks)); reportPlaybackProgress(state, getReportingParams(state)); return Promise.resolve(); @@ -474,79 +505,41 @@ export async function changeStream( params = params || {}; - const playSessionId = state.playSessionId; - const liveStreamId = state.liveStreamId; - - const item = state.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); + //} + + // @ts-expect-error is possible here + return await PlaybackManager.playItemInternal(state.item, { + audioStreamIndex: + params.AudioStreamIndex == null + ? state.audioStreamIndex + : params.AudioStreamIndex, + liveStreamId: state.liveStreamId, + mediaSourceId: state.mediaSourceId, + startPositionTicks: ticks, + subtitleStreamIndex: + params.SubtitleStreamIndex == null + ? state.subtitleStreamIndex + : params.SubtitleStreamIndex }); - const audioStreamIndex = - params.AudioStreamIndex == null - ? state.audioStreamIndex - : params.AudioStreamIndex; - const subtitleStreamIndex = - params.SubtitleStreamIndex == null - ? state.subtitleStreamIndex - : params.SubtitleStreamIndex; - - const playbackInformation = await getPlaybackInfo( - item, - maxBitrate, - deviceProfile, - ticks, - state.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.playerManager.pause(); - await stopActiveEncodings(state); - } - - window.playerManager.load(loadRequest); - window.playerManager.play(); - state.subtitleStreamIndex = subtitleStreamIndex; - state.audioStreamIndex = audioStreamIndex; } // Create a message handler for the custome namespace channel // TODO save namespace somewhere global? window.castReceiverContext.addCustomMessageListener( 'urn:x-cast:com.connectsdk', + // eslint-disable-next-line @typescript-eslint/no-explicit-any (evt: any) => { - let data: any = evt.data; + let data: any = evt.data; // eslint-disable-line @typescript-eslint/no-explicit-any // Apparently chromium likes to pass it as json, not as object. // chrome on android works fine @@ -566,12 +559,14 @@ window.castReceiverContext.addCustomMessageListener( ); /** - * @param data - * @param options - * @param method + * translateItems + * @param data - data + * @param options - options + * @param method - method + * @returns promise */ export async function translateItems( - data: any, + data: any, // eslint-disable-line @typescript-eslint/no-explicit-any options: PlayRequest, method: string ): Promise { @@ -589,46 +584,55 @@ 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); } } /** - * @param data - * @param options - * @param item + * instantMix + * @param data - data + * @param options - options + * @param item - item + * @returns promise */ export async function instantMix( + // eslint-disable-next-line @typescript-eslint/no-explicit-any data: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any options: any, item: BaseItemDto ): Promise { const result = await getInstantMixItems(data.userId, item); options.items = result.Items; - playbackMgr.playFromOptions(data.options); + PlaybackManager.playFromOptions(data.options); } /** - * @param data - * @param options - * @param item + * shuffle + * @param data - data + * @param options - options + * @param item - item + * @returns promise */ export async function shuffle( + // eslint-disable-next-line @typescript-eslint/no-explicit-any data: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any options: any, item: BaseItemDto ): Promise { const result = await getShuffleItems(data.userId, item); options.items = result.Items; - playbackMgr.playFromOptions(data.options); + PlaybackManager.playFromOptions(data.options); } /** + * onStopPlayerBeforePlaybackDone * 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 @@ -637,6 +641,7 @@ export async function shuffle( */ export async function onStopPlayerBeforePlaybackDone( item: BaseItemDto, + // eslint-disable-next-line @typescript-eslint/no-explicit-any options: any ): Promise { const data = await JellyfinApi.authAjaxUser(`Items/${item.Id}`, { @@ -644,13 +649,14 @@ export async function onStopPlayerBeforePlaybackDone( type: 'GET' }); - playbackMgr.playItemInternal(data, options); + PlaybackManager.playItemInternal(data, options); } let lastBitrateDetect = 0; let detectedBitrate = 0; /** - * + * getMaxBitrate + * @returns promise */ export async function getMaxBitrate(): Promise { console.log('getMaxBitrate'); @@ -678,7 +684,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.'); @@ -688,28 +694,19 @@ 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 + * showPlaybackInfoErrorMessage + * @param error - error */ export function showPlaybackInfoErrorMessage(error: string): void { broadcastToMessageBus({ message: error, type: 'playbackerror' }); } /** - * @param versions + * getOptimalMediaSource + * @param versions - versions + * @returns stream */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any export function getOptimalMediaSource(versions: Array): any { let optimalVersion = versions.filter((v) => { checkDirectPlay(v); @@ -733,7 +730,8 @@ export function getOptimalMediaSource(versions: Array): any { // Disable direct play on non-http sources /** - * @param mediaSource + * checkDirectPlay + * @param mediaSource - mediaSource */ export function checkDirectPlay(mediaSource: MediaSourceInfo): void { if ( @@ -749,7 +747,8 @@ export function checkDirectPlay(mediaSource: MediaSourceInfo): void { } /** - * @param index + * setTextTrack + * @param index - index */ export function setTextTrack(index: number | null): void { try { @@ -832,13 +831,16 @@ export function setTextTrack(index: number | null): void { // TODO no any types /** - * @param playSessionId - * @param item - * @param streamInfo + * createMediaInformation + * @param playSessionId - playSessionId + * @param item - item + * @param streamInfo - streamInfo + * @returns media information */ export function createMediaInformation( playSessionId: string, item: BaseItemDto, + // eslint-disable-next-line @typescript-eslint/no-explicit-any streamInfo: any ): framework.messages.MediaInformation { const mediaInfo = new cast.framework.messages.MediaInformation(); @@ -867,7 +869,7 @@ export function createMediaInformation( if (streamInfo.mediaSource.RunTimeTicks) { mediaInfo.duration = Math.floor( - streamInfo.mediaSource.RunTimeTicks / 10000000 + ticksToSeconds(streamInfo.mediaSource.RunTimeTicks) ); } diff --git a/src/components/playbackManager.ts b/src/components/playbackManager.ts index 07bba9bb..a746d326 100644 --- a/src/components/playbackManager.ts +++ b/src/components/playbackManager.ts @@ -3,25 +3,21 @@ import type { MediaSourceInfo, PlayMethod } from '@jellyfin/sdk/lib/generated-client'; - +import { RepeatMode } from '@jellyfin/sdk/lib/generated-client'; +import { AppStatus } from '../types/appStatus'; import { - getNextPlaybackItemInfo, broadcastConnectionErrorMessage, - getReportingParams, - createStreamInfo + createStreamInfo, + ticksToSeconds } from '../helpers'; - +import { DocumentManager } from './documentManager'; +import { getDeviceProfile } from './deviceprofileBuilder'; import { getPlaybackInfo, getLiveStream, load, - reportPlaybackStart, - stop, - stopPingInterval, - reportPlaybackStopped + stopPingInterval } from './jellyfinActions'; -import { getDeviceProfile } from './deviceprofileBuilder'; - import { onStopPlayerBeforePlaybackDone, getMaxBitrate, @@ -30,8 +26,7 @@ import { checkDirectPlay, createMediaInformation } from './maincontroller'; - -import { DocumentManager } from './documentManager'; +import { ItemIndex } from '~/types/global'; export interface PlaybackState { startPositionTicks: number; @@ -56,13 +51,12 @@ export interface PlaybackState { runtimeTicks: number; } -export class playbackManager { - private playerManager: framework.PlayerManager; - // TODO remove any - private activePlaylist: Array; - private activePlaylistIndex: number; +export abstract class PlaybackManager { + private static playerManager: framework.PlayerManager; + private static activePlaylist: Array; + private static activePlaylistIndex: number; - playbackState: PlaybackState = { + static playbackState: PlaybackState = { audioStreamIndex: null, canSeek: false, isChangingStream: false, @@ -81,13 +75,10 @@ export class playbackManager { subtitleStreamIndex: null }; - constructor(playerManager: framework.PlayerManager) { + 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 @@ -95,8 +86,8 @@ 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 || @@ -105,7 +96,15 @@ export class playbackManager { ); } - async playFromOptions(options: any): Promise { + static isBuffering(): boolean { + return ( + this.playerManager.getPlayerState() === + cast.framework.messages.PlayerState.BUFFERING + ); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + static async playFromOptions(options: any): Promise { const firstItem = options.items[0]; if (options.startPositionTicks || firstItem.MediaType !== 'Video') { @@ -115,26 +114,48 @@ export class playbackManager { return this.playFromOptionsInternal(options); } - playFromOptionsInternal(options: any): boolean { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + 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); - return this.playNextItem(options, stopPlayer); + // When starting playback initially, don't use + // the next item facility. + return this.playItem(options, stopPlayer); } - playNextItem(options: any = {}, stopPlayer = false): boolean { - const nextItemInfo = getNextPlaybackItemInfo(); + // add item to playlist + static enqueue(item: BaseItemDto): void { + this.activePlaylist.push(item); + } - if (nextItemInfo) { - this.activePlaylistIndex = nextItemInfo.index; + static resetPlaylist(): void { + this.activePlaylistIndex = -1; + this.activePlaylist = []; + } + + // If there are items in the queue after the current one + static hasNextItem(): boolean { + return this.activePlaylistIndex < this.activePlaylist.length - 1; + } - const item = nextItemInfo.item; + // If there are items in the queue before the current one + static hasPrevItem(): boolean { + return this.activePlaylistIndex > 0; + } - this.playItem(item, options, stopPlayer); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + static playNextItem(options: any = {}, stopPlayer = false): boolean { + const nextItemInfo = this.getNextPlaybackItemInfo(); + + if (nextItemInfo) { + this.activePlaylistIndex = nextItemInfo.index; + this.playItem(options, stopPlayer); return true; } @@ -142,13 +163,11 @@ export class playbackManager { return false; } - playPreviousItem(options: any = {}): boolean { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + 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; } @@ -156,21 +175,29 @@ export class playbackManager { return false; } - async playItem( - item: BaseItemDto, - options: any, + // play item from playlist + private static async playItem( + options: any, // eslint-disable-line @typescript-eslint/no-explicit-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 // eslint-disable-line @typescript-eslint/no-explicit-any + ): Promise { this.playbackState.isChangingStream = false; - DocumentManager.setAppStatus('loading'); + DocumentManager.setAppStatus(AppStatus.Loading); const maxBitrate = await getMaxBitrate(); const deviceProfile = getDeviceProfile({ @@ -184,7 +211,8 @@ export class playbackManager { options.startPositionTicks, options.mediaSourceId, options.audioStreamIndex, - options.subtitleStreamIndex + options.subtitleStreamIndex, + options.liveStreamId ).catch(broadcastConnectionErrorMessage); if (playbackInfo.ErrorCode) { @@ -227,14 +255,13 @@ export class playbackManager { ); } - // TODO eradicate any - playMediaSource( + private static playMediaSource( playSessionId: string, item: BaseItemDto, mediaSource: MediaSourceInfo, - options: any + options: any // eslint-disable-line @typescript-eslint/no-explicit-any ): void { - DocumentManager.setAppStatus('loading'); + DocumentManager.setAppStatus(AppStatus.Loading); const streamInfo = createStreamInfo( item, @@ -242,8 +269,6 @@ export class playbackManager { options.startPositionTicks ); - const url = streamInfo.url; - const mediaInfo = createMediaInformation( playSessionId, item, @@ -254,20 +279,26 @@ export class playbackManager { loadRequestData.media = mediaInfo; loadRequestData.autoplay = true; - load(this, mediaInfo.customData, item); + // If we should seek at the start, translate it + // to seconds and give it to loadRequestData :) + if (mediaInfo.customData.startPositionTicks > 0) { + loadRequestData.currentTime = ticksToSeconds( + mediaInfo.customData.startPositionTicks + ); + } + + load(mediaInfo.customData, item); this.playerManager.load(loadRequestData); this.playbackState.PlaybackMediaSource = mediaSource; - console.log(`setting src to ${url}`); + console.log(`setting src to ${streamInfo.url}`); this.playbackState.mediaSource = mediaSource; DocumentManager.setPlayerBackdrop(item); - reportPlaybackStart( - this.playbackState, - getReportingParams(this.playbackState) - ); + this.playbackState.audioStreamIndex = streamInfo.audioStreamIndex; + this.playbackState.subtitleStreamIndex = streamInfo.subtitleStreamIndex; // 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 @@ -275,39 +306,80 @@ export class playbackManager { this.playerManager.setMediaInformation(mediaInfo, false); } - stop(continuing = false): Promise { - this.playbackState.playNextItemBool = continuing; - stop(); + /** + * stop playback, as requested by the client + */ + static stop(): void { + this.playerManager.stop(); + // onStop will be called when playback comes to a halt. + } - const reportingParams = getReportingParams(this.playbackState); + /** + * Called when media stops playing. + * TODO avoid doing this between tracks in a playlist + */ + static onStop(): void { + if (this.getNextPlaybackItemInfo()) { + this.playbackState.playNextItemBool = true; + } else { + this.playbackState.playNextItemBool = false; - let promise; + DocumentManager.setAppStatus(AppStatus.Waiting); - stopPingInterval(); + stopPingInterval(); - if (reportingParams.ItemId) { - promise = reportPlaybackStopped( - this.playbackState, - 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 RepeatMode.RepeatOne: + newIndex = this.activePlaylistIndex; + break; + case RepeatMode.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; } /** * Attempt to clean the receiver state. */ - resetPlaybackScope(): void { - DocumentManager.setAppStatus('waiting'); + static resetPlaybackScope(): void { + DocumentManager.setAppStatus(AppStatus.Waiting); this.playbackState.startPositionTicks = 0; DocumentManager.setWaitingBackdrop(null, null); diff --git a/src/helpers.ts b/src/helpers.ts index 290af95a..18fd2ff1 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -6,10 +6,11 @@ import type { BaseItemPerson, UserDto } from '@jellyfin/sdk/lib/generated-client'; - import { JellyfinApi } from './components/jellyfinApi'; -import { BusMessage, ItemIndex, ItemQuery } from './types/global'; -import { PlaybackState } from './components/playbackManager'; +import { PlaybackManager, PlaybackState } from './components/playbackManager'; +import { BusMessage, ItemQuery } from './types/global'; + +export const TicksPerSecond = 10000000; /** * Get current playback position in ticks, adjusted for server seeking @@ -17,7 +18,8 @@ import { PlaybackState } from './components/playbackManager'; * @returns position in ticks */ export function getCurrentPositionTicks(state: PlaybackState): number { - let positionTicks = window.playerManager.getCurrentTimeSec() * 10000000; + let positionTicks = + window.playerManager.getCurrentTimeSec() * TicksPerSecond; const mediaInformation = window.playerManager.getMediaInformation(); if (mediaInformation && !mediaInformation.customData.canClientSeek) { @@ -58,52 +60,7 @@ export function getReportingParams(state: PlaybackState): 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; -} - -/** + * getSenderReportingData * 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 * the connected client (or clients?). @@ -114,7 +71,9 @@ export function getNextPlaybackItemInfo(): ItemIndex | null { export function getSenderReportingData( playbackState: PlaybackState, reportingData: PlaybackProgressInfo + // eslint-disable-next-line @typescript-eslint/no-explicit-any ): any { + // eslint-disable-next-line @typescript-eslint/no-explicit-any const state: any = { ItemId: reportingData.ItemId, PlayState: reportingData, @@ -135,6 +94,7 @@ export function getSenderReportingData( nowPlayingItem.Chapters = item.Chapters || []; // TODO: Fill these + // eslint-disable-next-line @typescript-eslint/no-explicit-any const mediaSource = item.MediaSources?.filter((m: any) => { return m.Id == reportingData.MediaSourceId; })[0]; @@ -194,7 +154,7 @@ export function getSenderReportingData( } if (playbackState.playNextItemBool) { - const nextItemInfo = getNextPlaybackItemInfo(); + const nextItemInfo = PlaybackManager.getNextPlaybackItemInfo(); if (nextItemInfo) { state.NextMediaType = nextItemInfo.item.MediaType; @@ -210,7 +170,9 @@ export function getSenderReportingData( * @param item - item to look up * @returns one of the metadata classes in cast.framework.messages.*Metadata */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any export function getMetadata(item: BaseItemDto): any { + // eslint-disable-next-line @typescript-eslint/no-explicit-any let metadata: any; let posterUrl = ''; @@ -312,6 +274,15 @@ export function getMetadata(item: BaseItemDto): any { return metadata; } +/** + * Check if a media source is an HLS stream + * @param mediaSource - mediaSource + * @returns boolean + */ +export function isHlsStream(mediaSource: MediaSourceInfo): boolean { + return mediaSource.TranscodingSubProtocol == 'hls'; +} + /** * Create the necessary information about an item * needed for playback @@ -324,13 +295,14 @@ export function createStreamInfo( item: BaseItemDto, mediaSource: MediaSourceInfo, startPosition: number | null + // eslint-disable-next-line @typescript-eslint/no-explicit-any ): any { let mediaUrl; let contentType; // server seeking const startPositionInSeekParam = startPosition - ? startPosition / 10000000 + ? ticksToSeconds(startPosition) : 0; const seekParam = startPositionInSeekParam ? `#t=${startPositionInSeekParam}` @@ -361,7 +333,7 @@ export function createStreamInfo( mediaSource.TranscodingUrl ); - if (mediaSource.TranscodingSubProtocol == 'hls') { + if (isHlsStream(mediaSource)) { mediaUrl += seekParam; playerStartPositionTicks = startPosition || 0; contentType = 'application/x-mpegURL'; @@ -412,6 +384,7 @@ export function createStreamInfo( // It is a pain and will require unbinding all event handlers during the operation const canSeek = (mediaSource.RunTimeTicks || 0) > 0; + // eslint-disable-next-line @typescript-eslint/no-explicit-any const info: any = { audioStreamIndex: mediaSource.DefaultAudioStreamIndex, canClientSeek: isStatic || (canSeek && streamContainer == 'm3u8'), @@ -427,11 +400,13 @@ export function createStreamInfo( }; const subtitleStreams = + // eslint-disable-next-line @typescript-eslint/no-explicit-any mediaSource.MediaStreams?.filter((stream: any) => { return stream.Type === 'Subtitle'; }) ?? []; const subtitleTracks: Array = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any subtitleStreams.forEach((subtitleStream: any) => { if (subtitleStream.DeliveryUrl === undefined) { /* The CAF v3 player only supports vtt currently, @@ -439,7 +414,7 @@ export function createStreamInfo( * The server will do that in accordance with the device profiles and * give us a DeliveryUrl if that is the case. * Support for more could be added with a custom implementation - **/ + */ return; } @@ -476,9 +451,11 @@ export function createStreamInfo( * @returns first first matching stream */ export function getStreamByIndex( + // eslint-disable-next-line @typescript-eslint/no-explicit-any streams: Array, type: string, index: number + // eslint-disable-next-line @typescript-eslint/no-explicit-any ): any { return streams.filter((s) => { return s.Type == type && s.Index == index; @@ -540,6 +517,7 @@ export async function getInstantMixItems( userId: string, item: BaseItemDto ): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any const query: any = { Fields: requiredItemFields, Limit: 50, @@ -775,6 +753,15 @@ export function parseISO8601Date(date: string): Date { return new Date(date); } +/** + * Convert ticks to seconds + * @param ticks - number of ticks to convert + * @returns number of seconds + */ +export function ticksToSeconds(ticks: number): number { + return ticks / TicksPerSecond; +} + /** * Send a message over the custom message transport * @param message - to send diff --git a/src/types/appStatus.ts b/src/types/appStatus.ts new file mode 100644 index 00000000..cf9256b1 --- /dev/null +++ b/src/types/appStatus.ts @@ -0,0 +1,10 @@ +export enum AppStatus { + Audio = 'Audio', + Backdrop = 'Backdrop', + Details = 'Details', + Loading = 'Loading', + PlayingWithControls = 'PlayingWithControls', + Playing = 'Playing', + Unset = '', + Waiting = 'Waiting' +} diff --git a/src/types/global.d.ts b/src/types/global.d.ts index cc18a3a8..893fb7be 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -96,10 +96,9 @@ declare global { mediaElement: HTMLElement | null; playerManager: PlayerManager; castReceiverContext: CastReceiverContext; - playlist: Array; - currentPlaylistIndex: number; repeatMode: RepeatMode; reportEventType: 'repeatmodechange'; + // eslint-disable-next-line @typescript-eslint/no-explicit-any subtitleAppearance: any; MaxBitrate: number | undefined; senderId: string | undefined;