diff --git a/src/apps/experimental/components/AppToolbar/SyncPlayButton.tsx b/src/apps/experimental/components/AppToolbar/SyncPlayButton.tsx index edaa6f83ab..3ac58410f8 100644 --- a/src/apps/experimental/components/AppToolbar/SyncPlayButton.tsx +++ b/src/apps/experimental/components/AppToolbar/SyncPlayButton.tsx @@ -8,9 +8,9 @@ import React, { useCallback, useState } from 'react'; import { QUERY_KEY, useSyncPlayGroups } from 'apps/experimental/features/syncPlay/hooks/api/useSyncPlayGroups'; import { useSyncPlay } from 'apps/experimental/features/syncPlay/hooks/useSyncPlay'; import { pluginManager } from 'components/pluginManager'; +import { PluginType } from 'constants/pluginType'; import { useApi } from 'hooks/useApi'; import globalize from 'lib/globalize'; -import { PluginType } from 'types/plugin'; import { queryClient } from 'utils/query/queryClient'; import AppSyncPlayMenu, { ID } from './menus/SyncPlayMenu'; diff --git a/src/apps/experimental/features/preferences/hooks/useScreensavers.ts b/src/apps/experimental/features/preferences/hooks/useScreensavers.ts index ac609d37f2..6527c71bd1 100644 --- a/src/apps/experimental/features/preferences/hooks/useScreensavers.ts +++ b/src/apps/experimental/features/preferences/hooks/useScreensavers.ts @@ -1,7 +1,8 @@ import { useMemo } from 'react'; import { pluginManager } from 'components/pluginManager'; -import { Plugin, PluginType } from 'types/plugin'; +import { PluginType } from 'constants/pluginType'; +import type { Plugin } from 'types/plugin'; import globalize from 'lib/globalize'; export function useScreensavers() { diff --git a/src/apps/experimental/features/syncPlay/hooks/useSyncPlay.ts b/src/apps/experimental/features/syncPlay/hooks/useSyncPlay.ts index 96ad3ad9da..65cddb9f4f 100644 --- a/src/apps/experimental/features/syncPlay/hooks/useSyncPlay.ts +++ b/src/apps/experimental/features/syncPlay/hooks/useSyncPlay.ts @@ -3,7 +3,7 @@ import type { ApiClient } from 'jellyfin-apiclient'; import { useCallback, useEffect, useState } from 'react'; import { pluginManager } from 'components/pluginManager'; -import { PluginType } from 'types/plugin'; +import { PluginType } from 'constants/pluginType'; import Events, { type Event } from 'utils/events'; interface SyncPlayInstance { diff --git a/src/components/displaySettings/displaySettings.js b/src/components/displaySettings/displaySettings.js index 4079e8e8e1..f94f1e0437 100644 --- a/src/components/displaySettings/displaySettings.js +++ b/src/components/displaySettings/displaySettings.js @@ -1,6 +1,7 @@ import escapeHtml from 'escape-html'; import { AppFeature } from 'constants/appFeature'; +import { PluginType } from 'constants/pluginType'; import { getUserQuery } from 'hooks/api/useUser'; import { ServerConnections } from 'lib/jellyfin-apiclient'; import { toApi } from 'utils/jellyfin-apiclient/compat'; @@ -15,7 +16,6 @@ import datetime from '../../scripts/datetime'; import globalize from '../../lib/globalize'; import loading from '../loading/loading'; import skinManager from '../../scripts/themeManager'; -import { PluginType } from '../../types/plugin.ts'; import Events from '../../utils/events.ts'; import toast from '../toast/toast'; diff --git a/src/components/playback/playbackmanager.js b/src/components/playback/playbackmanager.js index 47a83848c1..fc80d25fea 100644 --- a/src/components/playback/playbackmanager.js +++ b/src/components/playback/playbackmanager.js @@ -18,7 +18,6 @@ import globalize from '../../lib/globalize'; import loading from '../loading/loading'; import { appHost } from '../apphost'; import alert from '../alert'; -import { PluginType } from '../../types/plugin.ts'; import { includesAny } from '../../utils/container.ts'; import { getItems } from '../../utils/jellyfin-apiclient/getItems.ts'; import { getItemBackdropImageUrl } from '../../utils/jellyfin-apiclient/backdropImage'; @@ -27,6 +26,7 @@ import { PlayerEvent } from 'apps/stable/features/playback/constants/playerEvent import { bindMediaSegmentManager } from 'apps/stable/features/playback/utils/mediaSegmentManager'; import { bindMediaSessionSubscriber } from 'apps/stable/features/playback/utils/mediaSessionSubscriber'; import { AppFeature } from 'constants/appFeature'; +import { PluginType } from 'constants/pluginType'; import { TICKS_PER_SECOND } from 'constants/time'; import { ServerConnections } from 'lib/jellyfin-apiclient'; import { OutboundWebSocketMessageType } from '@jellyfin/sdk/lib/websocket'; @@ -2364,23 +2364,21 @@ export class PlaybackManager { // Normalize defaults to simplfy checks throughout the process normalizePlayOptions(playOptions); - if (playOptions.isFirstItem) { - playOptions.isFirstItem = false; - } else { - playOptions.isFirstItem = true; - } + playOptions.isFirstItem = playOptions.isFirstItem || !prevSource; + const apiClient = ServerConnections.getApiClient(item.ServerId); // TODO: This should be the media type requested, not the original media type const mediaType = item.MediaType; - if (playOptions.fullscreen) { - loading.show(); - } - return runInterceptors(item, playOptions) .catch(onInterceptorRejection) + .then(() => { + if (playOptions.fullscreen) { + loading.show(); + } + }) .then(() => detectBitrate(apiClient, item, mediaType)) .then((bitrate) => { return playAfterBitrateDetect(bitrate, item, playOptions, onPlaybackStartedFn, prevSource) @@ -2437,7 +2435,7 @@ export class PlaybackManager { const interceptors = pluginManager.ofType(PluginType.PreplayIntercept); interceptors.sort(function (a, b) { - return (a.order || 0) - (b.order || 0); + return (a.priority || 0) - (b.priority || 0); }); if (!interceptors.length) { diff --git a/src/components/playerstats/playerstats.js b/src/components/playerstats/playerstats.js index 19c22786ac..b475d073ca 100644 --- a/src/components/playerstats/playerstats.js +++ b/src/components/playerstats/playerstats.js @@ -1,6 +1,6 @@ +import { PluginType } from 'constants/pluginType'; import globalize from 'lib/globalize'; import { ServerConnections } from 'lib/jellyfin-apiclient'; -import { PluginType } from 'types/plugin'; import Events from 'utils/events'; import { getReadableSize } from 'utils/file'; diff --git a/src/components/playlisteditor/playlisteditor.ts b/src/components/playlisteditor/playlisteditor.ts index 8d3a7782ad..633e8e05e5 100644 --- a/src/components/playlisteditor/playlisteditor.ts +++ b/src/components/playlisteditor/playlisteditor.ts @@ -6,11 +6,11 @@ import { getUserLibraryApi } from '@jellyfin/sdk/lib/utils/api/user-library-api' import escapeHtml from 'escape-html'; import toast from 'components/toast/toast'; +import { PluginType } from 'constants/pluginType'; import dom from 'utils/dom'; import globalize from 'lib/globalize'; import { ServerConnections } from 'lib/jellyfin-apiclient'; import { currentSettings as userSettings } from 'scripts/settings/userSettings'; -import { PluginType } from 'types/plugin'; import { toApi } from 'utils/jellyfin-apiclient/compat'; import { isBlank } from 'utils/string'; diff --git a/src/config.json b/src/config.json index 3d80b1efec..f894af5e30 100644 --- a/src/config.json +++ b/src/config.json @@ -45,6 +45,7 @@ "logoScreensaver/plugin", "sessionPlayer/plugin", "chromecastPlayer/plugin", + "stillWatching/plugin", "syncPlay/plugin" ] } diff --git a/src/constants/pluginType.ts b/src/constants/pluginType.ts new file mode 100644 index 0000000000..d387e2e5b3 --- /dev/null +++ b/src/constants/pluginType.ts @@ -0,0 +1,7 @@ +/** The type of a web plugin. */ +export enum PluginType { + MediaPlayer = 'mediaplayer', + PreplayIntercept = 'preplayintercept', + Screensaver = 'screensaver', + SyncPlay = 'syncplay' +} diff --git a/src/constants/time.ts b/src/constants/time.ts index 8d2f720b0b..c5ca5b4018 100644 --- a/src/constants/time.ts +++ b/src/constants/time.ts @@ -1,6 +1,9 @@ /** The number of milliseconds per second */ export const MILLISECONDS_PER_SECOND = 1_000; +/** The number of milliseconds per minute */ +export const MILLISECONDS_PER_MINUTE = 60 * MILLISECONDS_PER_SECOND; + /** The number of ticks per millisecond */ export const TICKS_PER_MILLISECOND = 10_000; diff --git a/src/controllers/playback/video/index.js b/src/controllers/playback/video/index.js index b6c6c50206..514fe7dcf9 100644 --- a/src/controllers/playback/video/index.js +++ b/src/controllers/playback/video/index.js @@ -2,6 +2,7 @@ import escapeHtml from 'escape-html'; import { PlayerEvent } from 'apps/stable/features/playback/constants/playerEvent'; import { AppFeature } from 'constants/appFeature'; +import { PluginType } from 'constants/pluginType'; import { TICKS_PER_MINUTE, TICKS_PER_SECOND } from 'constants/time'; import { EventType } from 'constants/eventType'; @@ -32,7 +33,6 @@ import { ServerConnections } from 'lib/jellyfin-apiclient'; import LibraryMenu from '../../../scripts/libraryMenu'; import { setBackdropTransparency, TRANSPARENCY_LEVEL } from '../../../components/backdrop/backdrop'; import { pluginManager } from '../../../components/pluginManager'; -import { PluginType } from '../../../types/plugin.ts'; function getOpenedDialog() { return document.querySelector('.dialogContainer .dialog.opened'); diff --git a/src/plugins/backdropScreensaver/plugin.js b/src/plugins/backdropScreensaver/plugin.js index fc22fbb4bd..be01c18cc8 100644 --- a/src/plugins/backdropScreensaver/plugin.js +++ b/src/plugins/backdropScreensaver/plugin.js @@ -1,7 +1,6 @@ - +import { PluginType } from 'constants/pluginType'; import { ServerConnections } from 'lib/jellyfin-apiclient'; import * as userSettings from 'scripts/settings/userSettings'; -import { PluginType } from 'types/plugin.ts'; class BackdropScreensaver { constructor() { diff --git a/src/plugins/bookPlayer/plugin.js b/src/plugins/bookPlayer/plugin.js index f8c6cd5354..af3182db3d 100644 --- a/src/plugins/bookPlayer/plugin.js +++ b/src/plugins/bookPlayer/plugin.js @@ -1,6 +1,7 @@ import { getLibraryApi } from '@jellyfin/sdk/lib/utils/api/library-api'; import Screenfull from 'screenfull'; +import { PluginType } from 'constants/pluginType'; import { ServerConnections } from 'lib/jellyfin-apiclient'; import browser from 'scripts/browser'; import TouchHelper from 'scripts/touchHelper'; @@ -13,7 +14,6 @@ import dialogHelper from '../../components/dialogHelper/dialogHelper'; import TableOfContents from './tableOfContents'; import { translateHtml } from '../../lib/globalize'; import * as userSettings from '../../scripts/settings/userSettings'; -import { PluginType } from '../../types/plugin.ts'; import Events from '../../utils/events.ts'; import 'material-design-icons-iconfont'; diff --git a/src/plugins/chromecastPlayer/plugin.js b/src/plugins/chromecastPlayer/plugin.js index 569d0e4eb9..bffee87edd 100644 --- a/src/plugins/chromecastPlayer/plugin.js +++ b/src/plugins/chromecastPlayer/plugin.js @@ -1,3 +1,5 @@ +import { PluginType } from 'constants/pluginType'; + import appSettings from '../../scripts/settings/appSettings'; import * as userSettings from '../../scripts/settings/userSettings'; import { playbackManager } from '../../components/playback/playbackmanager'; @@ -5,7 +7,6 @@ import globalize from '../../lib/globalize'; import CastSenderApi from './castSenderApi'; import alert from '../../components/alert'; import { ServerConnections } from 'lib/jellyfin-apiclient'; -import { PluginType } from '../../types/plugin.ts'; import Events from '../../utils/events.ts'; import { getItems } from '../../utils/jellyfin-apiclient/getItems.ts'; diff --git a/src/plugins/comicsPlayer/plugin.js b/src/plugins/comicsPlayer/plugin.js index 40465da852..7f3f4d8fac 100644 --- a/src/plugins/comicsPlayer/plugin.js +++ b/src/plugins/comicsPlayer/plugin.js @@ -1,6 +1,7 @@ import { getLibraryApi } from '@jellyfin/sdk/lib/utils/api/library-api'; import { Archive } from 'libarchive.js'; +import { PluginType } from 'constants/pluginType'; import { ServerConnections } from 'lib/jellyfin-apiclient'; import { toApi } from 'utils/jellyfin-apiclient/compat'; @@ -9,7 +10,6 @@ import dialogHelper from '../../components/dialogHelper/dialogHelper'; import keyboardnavigation from '../../scripts/keyboardNavigation'; import { appRouter } from '../../components/router/appRouter'; import * as userSettings from '../../scripts/settings/userSettings'; -import { PluginType } from '../../types/plugin.ts'; import './style.scss'; diff --git a/src/plugins/experimentalWarnings/plugin.js b/src/plugins/experimentalWarnings/plugin.js index 4eb5ef83da..71f5789079 100644 --- a/src/plugins/experimentalWarnings/plugin.js +++ b/src/plugins/experimentalWarnings/plugin.js @@ -1,8 +1,9 @@ +import { PluginType } from 'constants/pluginType'; + import globalize from '../../lib/globalize'; import * as userSettings from '../../scripts/settings/userSettings'; import { appHost } from '../../components/apphost'; import alert from '../../components/alert'; -import { PluginType } from '../../types/plugin.ts'; // TODO: Replace with date-fns // https://stackoverflow.com/questions/6117814/get-week-of-year-in-javascript-like-in-php diff --git a/src/plugins/htmlAudioPlayer/plugin.js b/src/plugins/htmlAudioPlayer/plugin.js index 07f7e2d2f3..2e4f4f3a8b 100644 --- a/src/plugins/htmlAudioPlayer/plugin.js +++ b/src/plugins/htmlAudioPlayer/plugin.js @@ -1,4 +1,5 @@ import { AppFeature } from 'constants/appFeature'; +import { PluginType } from 'constants/pluginType'; import { MediaError } from 'types/mediaError'; import browser from '../../scripts/browser'; @@ -6,7 +7,6 @@ import { appHost } from '../../components/apphost'; import * as htmlMediaHelper from '../../components/htmlMediaHelper'; import profileBuilder from '../../scripts/browserDeviceProfile'; import { getIncludeCorsCredentials } from '../../scripts/settings/webSettings'; -import { PluginType } from '../../types/plugin.ts'; import Events from '../../utils/events.ts'; function getDefaultProfile() { diff --git a/src/plugins/htmlVideoPlayer/plugin.js b/src/plugins/htmlVideoPlayer/plugin.js index 6e5488ce1a..fb9775e64f 100644 --- a/src/plugins/htmlVideoPlayer/plugin.js +++ b/src/plugins/htmlVideoPlayer/plugin.js @@ -5,6 +5,7 @@ import Screenfull from 'screenfull'; import { useCustomSubtitles } from 'apps/stable/features/playback/utils/subtitleStyles'; import subtitleAppearanceHelper from 'components/subtitlesettings/subtitleappearancehelper'; import { AppFeature } from 'constants/appFeature'; +import { PluginType } from 'constants/pluginType'; import { ServerConnections } from 'lib/jellyfin-apiclient'; import { currentSettings as userSettings } from 'scripts/settings/userSettings'; import { MediaError } from 'types/mediaError'; @@ -40,7 +41,6 @@ import globalize from '../../lib/globalize'; import profileBuilder, { canPlaySecondaryAudio } from '../../scripts/browserDeviceProfile'; import { getIncludeCorsCredentials } from '../../scripts/settings/webSettings'; import { setBackdropTransparency, TRANSPARENCY_LEVEL } from '../../components/backdrop/backdrop'; -import { PluginType } from '../../types/plugin.ts'; import Events from '../../utils/events.ts'; import { includesAny } from '../../utils/container.ts'; import { isHls } from '../../utils/mediaSource.ts'; diff --git a/src/plugins/logoScreensaver/plugin.js b/src/plugins/logoScreensaver/plugin.js index 37535d0ff0..b030a41122 100644 --- a/src/plugins/logoScreensaver/plugin.js +++ b/src/plugins/logoScreensaver/plugin.js @@ -1,7 +1,7 @@ import icon from '@jellyfin/ux-web/icon-transparent.png'; -import { PluginType } from '../../types/plugin.ts'; -import { randomInt } from '../../utils/number.ts'; +import { PluginType } from 'constants/pluginType'; +import { randomInt } from 'utils/number'; export default function () { const self = this; diff --git a/src/plugins/pdfPlayer/plugin.js b/src/plugins/pdfPlayer/plugin.js index 965b81a97a..9f6925234e 100644 --- a/src/plugins/pdfPlayer/plugin.js +++ b/src/plugins/pdfPlayer/plugin.js @@ -1,5 +1,6 @@ import { getLibraryApi } from '@jellyfin/sdk/lib/utils/api/library-api'; +import { PluginType } from 'constants/pluginType'; import { toApi } from 'utils/jellyfin-apiclient/compat'; import loading from '../../components/loading/loading'; @@ -8,7 +9,6 @@ import dialogHelper from '../../components/dialogHelper/dialogHelper'; import dom from '../../utils/dom'; import { appRouter } from '../../components/router/appRouter'; import { ServerConnections } from 'lib/jellyfin-apiclient'; -import { PluginType } from '../../types/plugin.ts'; import Events from '../../utils/events.ts'; import './style.scss'; diff --git a/src/plugins/photoPlayer/plugin.js b/src/plugins/photoPlayer/plugin.js index aae01dd0e4..9d8080e1c0 100644 --- a/src/plugins/photoPlayer/plugin.js +++ b/src/plugins/photoPlayer/plugin.js @@ -1,6 +1,6 @@ +import { PluginType } from 'constants/pluginType'; import { ServerConnections } from 'lib/jellyfin-apiclient'; -import * as userSettings from '../../scripts/settings/userSettings'; -import { PluginType } from 'types/plugin.ts'; +import * as userSettings from 'scripts/settings/userSettings'; export default class PhotoPlayer { constructor() { diff --git a/src/plugins/playAccessValidation/plugin.js b/src/plugins/playAccessValidation/plugin.js index cae96f8c3a..54c249c0ca 100644 --- a/src/plugins/playAccessValidation/plugin.js +++ b/src/plugins/playAccessValidation/plugin.js @@ -1,7 +1,7 @@ import alert from 'components/alert'; +import { PluginType } from 'constants/pluginType'; import globalize from 'lib/globalize'; import { ServerConnections } from 'lib/jellyfin-apiclient'; -import { PluginType } from 'types/plugin.ts'; function showErrorMessage() { return alert(globalize.translate('MessagePlayAccessRestricted')); @@ -12,7 +12,7 @@ class PlayAccessValidation { this.name = 'Playback validation'; this.type = PluginType.PreplayIntercept; this.id = 'playaccessvalidation'; - this.order = -2; + this.priority = -2; } intercept(options) { diff --git a/src/plugins/sessionPlayer/plugin.js b/src/plugins/sessionPlayer/plugin.js index 1dd3d2f6e2..04ad027002 100644 --- a/src/plugins/sessionPlayer/plugin.js +++ b/src/plugins/sessionPlayer/plugin.js @@ -1,10 +1,11 @@ -import { playbackManager } from '../../components/playback/playbackmanager'; -import { ServerConnections } from 'lib/jellyfin-apiclient'; -import { PluginType } from '../../types/plugin.ts'; -import Events from '../../utils/events.ts'; import isEqual from 'lodash-es/isEqual'; import { OutboundWebSocketMessageType, PeriodicListenerInterval } from '@jellyfin/sdk/lib/websocket'; +import { playbackManager } from 'components/playback/playbackmanager'; +import { PluginType } from 'constants/pluginType'; +import { ServerConnections } from 'lib/jellyfin-apiclient'; +import Events from 'utils/events'; + function getActivePlayerId() { const info = playbackManager.getPlayerInfo(); return info ? info.id : null; diff --git a/src/plugins/stillWatching/plugin.ts b/src/plugins/stillWatching/plugin.ts new file mode 100644 index 0000000000..ed29448e95 --- /dev/null +++ b/src/plugins/stillWatching/plugin.ts @@ -0,0 +1,72 @@ +import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind'; + +import confirm from 'components/confirm/confirm'; +import { MILLISECONDS_PER_MINUTE } from 'constants/time'; +import globalize from 'lib/globalize'; +import inputManager from 'scripts/inputManager'; +import { type InterceptOptions, PreplayInterceptPlugin } from 'types/plugin'; + +/** List of item types that should display a still watching prompt. */ +const SUPPORTED_ITEM_TYPES: BaseItemKind[] = [ + BaseItemKind.Episode +]; + +// TODO: These thresholds should be configurable by the user +/** The minimum idle time required before showing the still watching prompt. */ +const MIN_IDLE_TIME = 90 * MILLISECONDS_PER_MINUTE; // 90 minutes +/** The number of items that must be played before showing the still watching prompt. */ +const MIN_PLAY_COUNT = 3; + +class StillWatchingPlugin extends PreplayInterceptPlugin { + name = 'Still Watching'; + id = 'still-watching'; + priority = 10; // Run after plugins that validate access + + /** The start time of the current play session or since the last reset. */ + private sessionStartTime?: Date; + /** A set of item IDs that have been played in the current session or since the last reset. */ + private playedItems = new Set(); + + /** Resets the play session, clearing the start time and played items. */ + private resetSession() { + this.sessionStartTime = new Date(); + this.playedItems.clear(); + } + + async intercept({ + isFirstItem, + item + }: InterceptOptions) { + // Reset the session at the start of a new play session + if (isFirstItem) this.resetSession(); + + // Bail if the item is not supported or if it lacks an ID + if (!item.Id || !item.Type || !SUPPORTED_ITEM_TYPES.includes(item.Type)) return; + + const id = item.Id; + const idleTime = inputManager.idleTime(); + const sessionDuration = this.sessionStartTime ? new Date().getTime() - this.sessionStartTime.getTime() : 0; + + if ( + // Check that the session has lasted at least the minimum idle time. + // This prevents the prompt from appearing early for short episodes or skipping episodes. + sessionDuration >= MIN_IDLE_TIME + // Check that the user has been idle for at least the minimum idle time or has played at least the minimum + // number of items. + && (idleTime >= MIN_IDLE_TIME || this.playedItems.size >= MIN_PLAY_COUNT) + ) { + return confirm({ + text: globalize.translate('ConfirmStillWatching'), + cancelText: globalize.translate('StopWatching'), + confirmText: globalize.translate('ContinueWatching') + }).then(() => { + this.resetSession(); + this.playedItems.add(id); + }); + } + + this.playedItems.add(id); + } +} + +export default StillWatchingPlugin; diff --git a/src/plugins/syncPlay/plugin.ts b/src/plugins/syncPlay/plugin.ts index 41f5715ab7..98b96b1251 100644 --- a/src/plugins/syncPlay/plugin.ts +++ b/src/plugins/syncPlay/plugin.ts @@ -1,11 +1,13 @@ -import { playbackManager } from '../../components/playback/playbackmanager'; +import { playbackManager } from 'components/playback/playbackmanager'; +import { PluginType } from 'constants/pluginType'; import { ServerConnections } from 'lib/jellyfin-apiclient'; -import Events from '../../utils/events'; +import type { Plugin } from 'types/plugin'; +import Events from 'utils/events'; + import SyncPlay from './core'; import SyncPlayNoActivePlayer from './ui/players/NoActivePlayer'; import SyncPlayHtmlVideoPlayer from './ui/players/HtmlVideoPlayer'; import SyncPlayHtmlAudioPlayer from './ui/players/HtmlAudioPlayer'; -import { Plugin, PluginType } from '../../types/plugin'; class SyncPlayPlugin implements Plugin { name: string; diff --git a/src/plugins/syncPlay/ui/groupSelectionMenu.js b/src/plugins/syncPlay/ui/groupSelectionMenu.js index 1b6a3d9631..ae0448846a 100644 --- a/src/plugins/syncPlay/ui/groupSelectionMenu.js +++ b/src/plugins/syncPlay/ui/groupSelectionMenu.js @@ -1,3 +1,6 @@ +import { PluginType } from 'constants/pluginType'; +import { ServerConnections } from 'lib/jellyfin-apiclient'; + import SyncPlaySettingsEditor from './settings/SettingsEditor'; import loading from '../../../components/loading/loading'; import toast from '../../../components/toast/toast'; @@ -5,8 +8,6 @@ import actionsheet from '../../../components/actionSheet/actionSheet'; import globalize from '../../../lib/globalize'; import playbackPermissionManager from './playbackPermissionManager'; import { pluginManager } from '../../../components/pluginManager'; -import { ServerConnections } from 'lib/jellyfin-apiclient'; -import { PluginType } from '../../../types/plugin.ts'; import Events from '../../../utils/events.ts'; import './groupSelectionMenu.scss'; diff --git a/src/plugins/syncPlay/ui/settings/SettingsEditor.js b/src/plugins/syncPlay/ui/settings/SettingsEditor.js index 9a33bcec91..b13978f939 100644 --- a/src/plugins/syncPlay/ui/settings/SettingsEditor.js +++ b/src/plugins/syncPlay/ui/settings/SettingsEditor.js @@ -2,6 +2,7 @@ * Module that displays an editor for changing SyncPlay settings. * @module components/syncPlay/settings/SettingsEditor */ +import { PluginType } from 'constants/pluginType'; import { setSetting } from '../../core/Settings'; import dialogHelper from '../../../../components/dialogHelper/dialogHelper'; @@ -10,7 +11,6 @@ import { pluginManager } from '../../../../components/pluginManager'; import loading from '../../../../components/loading/loading'; import toast from '../../../../components/toast/toast'; import globalize from '../../../../lib/globalize'; -import { PluginType } from '../../../../types/plugin.ts'; import Events from '../../../../utils/events.ts'; import 'material-design-icons-iconfont'; diff --git a/src/plugins/youtubePlayer/plugin.js b/src/plugins/youtubePlayer/plugin.js index 52a0e1359f..1061802c84 100644 --- a/src/plugins/youtubePlayer/plugin.js +++ b/src/plugins/youtubePlayer/plugin.js @@ -1,8 +1,9 @@ +import { PluginType } from 'constants/pluginType'; + import browser from '../../scripts/browser'; import { appRouter } from '../../components/router/appRouter'; import loading from '../../components/loading/loading'; import { setBackdropTransparency, TRANSPARENCY_LEVEL } from '../../components/backdrop/backdrop'; -import { PluginType } from '../../types/plugin.ts'; import Events from '../../utils/events.ts'; /* globals YT */ diff --git a/src/scripts/libraryMenu.js b/src/scripts/libraryMenu.js index 49f4ebbe2a..60a22d3408 100644 --- a/src/scripts/libraryMenu.js +++ b/src/scripts/libraryMenu.js @@ -2,6 +2,7 @@ import escapeHtml from 'escape-html'; import Headroom from 'headroom.js'; import { AppFeature } from 'constants/appFeature'; +import { PluginType } from 'constants/pluginType'; import { getUserViewsQuery } from 'hooks/api/useUserViews'; import globalize from 'lib/globalize'; import { ServerConnections } from 'lib/jellyfin-apiclient'; @@ -22,7 +23,6 @@ import browser from './browser'; import imageHelper from '../utils/image'; import { getMenuLinks } from '../scripts/settings/webSettings'; import Dashboard, { pageClassOn } from '../utils/dashboard'; -import { PluginType } from '../types/plugin.ts'; import Events from '../utils/events.ts'; import { getParameterByName } from '../utils/url.ts'; import datetime from '../scripts/datetime'; diff --git a/src/scripts/screensavermanager.js b/src/scripts/screensavermanager.js index 54872d5015..97560fca71 100644 --- a/src/scripts/screensavermanager.js +++ b/src/scripts/screensavermanager.js @@ -1,8 +1,8 @@ import { playbackManager } from 'components/playback/playbackmanager'; import { pluginManager } from 'components/pluginManager'; +import { PluginType } from 'constants/pluginType'; import { ServerConnections } from 'lib/jellyfin-apiclient'; -import { PluginType } from 'types/plugin.ts'; -import Events from 'utils/events.ts'; +import Events from 'utils/events'; import inputManager from './inputManager'; import * as userSettings from './settings/userSettings'; diff --git a/src/scripts/serverNotifications.js b/src/scripts/serverNotifications.js index 80a2de8fce..f7fee45449 100644 --- a/src/scripts/serverNotifications.js +++ b/src/scripts/serverNotifications.js @@ -1,14 +1,15 @@ +import { OutboundWebSocketMessageType } from '@jellyfin/sdk/lib/websocket'; + import alert from 'components/alert'; import focusManager from 'components/focusManager'; import { playbackManager } from 'components/playback/playbackmanager'; import { pluginManager } from 'components/pluginManager'; import { appRouter } from 'components/router/appRouter'; import toast from 'components/toast/toast'; +import { PluginType } from 'constants/pluginType'; import { ServerConnections } from 'lib/jellyfin-apiclient'; import inputManager from 'scripts/inputManager'; -import Events from 'utils/events.ts'; -import { PluginType } from 'types/plugin.ts'; -import { OutboundWebSocketMessageType } from '@jellyfin/sdk/lib/websocket'; +import Events from 'utils/events'; const serverNotifications = {}; diff --git a/src/strings/en-us.json b/src/strings/en-us.json index 4abb5aeb86..8bf4c2fa91 100644 --- a/src/strings/en-us.json +++ b/src/strings/en-us.json @@ -191,6 +191,7 @@ "ConfirmDeleteRepository": "Delete repository?", "ConfirmDeletion": "Confirm Deletion", "ConfirmEndPlayerSession": "Would you like to shut Jellyfin down on {0}?", + "ConfirmStillWatching": "Are you still watching?", "Connect": "Connect", "ConnectAnyway": "Connect Anyway", "Console": "Console", @@ -1596,6 +1597,7 @@ "StoryArc": "Story Arc", "StopPlayback": "Stop playback", "StopRecording": "Stop recording", + "StopWatching": "Stop watching", "StorageType.CDRom": "CD-ROM", "StorageType.Fixed": "Fixed", "StorageType.Network": "Network", diff --git a/src/types/playOptions.ts b/src/types/playOptions.ts new file mode 100644 index 0000000000..d4e00e3d89 --- /dev/null +++ b/src/types/playOptions.ts @@ -0,0 +1,22 @@ +import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto'; + +export interface PlayOptions { + /** The aspect ratio of the player */ + aspectRatio?: string + /** The index of the audio stream to play */ + audioStreamIndex?: number + /** Whether to play in fullscreen mode */ + fullscreen?: boolean + /** Whether this is the first item in the play queue */ + isFirstItem?: boolean + /** The list of items in the play queue */ + items?: BaseItemDto[] | null + /** The media source ID to play for the current item */ + mediaSourceId?: string + /** The index of the item to start playing from */ + startIndex?: number + /** The position to start playing from */ + startPositionTicks?: number + /** The index of the subtitle stream to play */ + subtitleStreamIndex?: number +} diff --git a/src/types/plugin.ts b/src/types/plugin.ts index acc07bcc5b..17f9b063b3 100644 --- a/src/types/plugin.ts +++ b/src/types/plugin.ts @@ -1,17 +1,52 @@ -export enum PluginType { - MediaPlayer = 'mediaplayer', - PreplayIntercept = 'preplayintercept', - Screensaver = 'screensaver', - SyncPlay = 'syncplay' -} +import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto'; +import type { MediaType } from '@jellyfin/sdk/lib/generated-client/models/media-type'; + +import { PluginType } from 'constants/pluginType'; + +import type { PlayOptions } from './playOptions'; export interface Plugin { + /** The name of the plugin */ name: string + /** The unique identifier for the plugin */ id: string + /** The type of the plugin */ type: PluginType | string + /** The priority of the plugin, used to determine the order of execution */ priority?: number } -export interface PlayerPlugin extends Plugin { - isLocalPlayer?: boolean +export abstract class PlayerPlugin implements Plugin { + type = PluginType.MediaPlayer; + /** The name of the plugin */ + abstract name: string; + /** The unique identifier for the plugin */ + abstract id: string; + /** The priority of the plugin, used to determine the order of execution */ + priority?: number; + /** Indicates if the player is a local player */ + isLocalPlayer?: boolean; +} + +export interface InterceptOptions extends PlayOptions { + /** The item being played */ + item: BaseItemDto + /** The media type of the item */ + mediaType?: MediaType +} + +export abstract class PreplayInterceptPlugin implements Plugin { + type = PluginType.PreplayIntercept; + /** The name of the plugin */ + abstract name: string; + /** The unique identifier for the plugin */ + abstract id: string; + /** The priority of the plugin, used to determine the order of execution */ + priority?: number; + + /** + * The function called prior to playback starting. + * Returning a rejected promise will prevent playback from starting, while a resolved promise will allow it to proceed. + */ + abstract intercept(options: InterceptOptions): Promise; }