diff --git a/src/apps/experimental/components/AppToolbar/RemotePlayButton.tsx b/src/apps/experimental/components/AppToolbar/RemotePlayButton.tsx new file mode 100644 index 0000000000..6749b61284 --- /dev/null +++ b/src/apps/experimental/components/AppToolbar/RemotePlayButton.tsx @@ -0,0 +1,112 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import CastConnected from '@mui/icons-material/CastConnected'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Cast from '@mui/icons-material/Cast'; +import IconButton from '@mui/material/IconButton'; +import { useTheme } from '@mui/material/styles'; +import Tooltip from '@mui/material/Tooltip'; + +import { playbackManager } from 'components/playback/playbackmanager'; +import globalize from 'scripts/globalize'; +import Events from 'utils/events'; + +import RemotePlayMenu, { ID } from './menus/RemotePlayMenu'; +import RemotePlayActiveMenu, { ID as ACTIVE_ID } from './menus/RemotePlayActiveMenu'; + +const RemotePlayButton = () => { + const theme = useTheme(); + const [ playerInfo, setPlayerInfo ] = useState(playbackManager.getPlayerInfo()); + + const updatePlayerInfo = useCallback(() => { + setPlayerInfo(playbackManager.getPlayerInfo()); + }, [ setPlayerInfo ]); + + useEffect(() => { + Events.on(playbackManager, 'playerchange', updatePlayerInfo); + + return () => { + Events.off(playbackManager, 'playerchange', updatePlayerInfo); + }; + }, [ updatePlayerInfo ]); + + const [ remotePlayMenuAnchorEl, setRemotePlayMenuAnchorEl ] = useState(null); + const isRemotePlayMenuOpen = Boolean(remotePlayMenuAnchorEl); + + const onRemotePlayButtonClick = useCallback((event) => { + setRemotePlayMenuAnchorEl(event.currentTarget); + }, [ setRemotePlayMenuAnchorEl ]); + + const onRemotePlayMenuClose = useCallback(() => { + setRemotePlayMenuAnchorEl(null); + }, [ setRemotePlayMenuAnchorEl ]); + + const [ remotePlayActiveMenuAnchorEl, setRemotePlayActiveMenuAnchorEl ] = useState(null); + const isRemotePlayActiveMenuOpen = Boolean(remotePlayActiveMenuAnchorEl); + + const onRemotePlayActiveButtonClick = useCallback((event) => { + setRemotePlayActiveMenuAnchorEl(event.currentTarget); + }, [ setRemotePlayActiveMenuAnchorEl ]); + + const onRemotePlayActiveMenuClose = useCallback(() => { + setRemotePlayActiveMenuAnchorEl(null); + }, [ setRemotePlayActiveMenuAnchorEl ]); + + return ( + <> + {(playerInfo && !playerInfo.isLocalPlayer) ? ( + + + + + + ) : ( + + + + + + )} + + + + + + ); +}; + +export default RemotePlayButton; diff --git a/src/apps/experimental/components/AppToolbar/index.tsx b/src/apps/experimental/components/AppToolbar/index.tsx index 0eeeecfc69..433fa4c836 100644 --- a/src/apps/experimental/components/AppToolbar/index.tsx +++ b/src/apps/experimental/components/AppToolbar/index.tsx @@ -15,6 +15,7 @@ import globalize from 'scripts/globalize'; import AppTabs from '../tabs/AppTabs'; import { isDrawerPath } from '../drawers/AppDrawer'; import UserMenuButton from './UserMenuButton'; +import RemotePlayButton from './RemotePlayButton'; interface AppToolbarProps { isDrawerOpen: boolean @@ -89,6 +90,8 @@ const AppToolbar: FC = ({ {isUserLoggedIn && ( <> + + void + playerInfo: { + name: string + isLocalPlayer: boolean + id?: string + deviceName?: string + playableMediaTypes?: string[] + supportedCommands?: string[] + } | null +} + +export const ID = 'app-remote-play-active-menu'; + +const RemotePlayActiveMenu: FC = ({ + anchorEl, + open, + onMenuClose, + playerInfo +}) => { + const [ isDisplayMirrorEnabled, setIsDisplayMirrorEnabled ] = useState(playbackManager.enableDisplayMirroring()); + const isDisplayMirrorSupported = playerInfo?.supportedCommands && playerInfo.supportedCommands.indexOf('DisplayContent') !== -1; + const toggleDisplayMirror = useCallback(() => { + playbackManager.enableDisplayMirroring(!isDisplayMirrorEnabled); + setIsDisplayMirrorEnabled(!isDisplayMirrorEnabled); + }, [ isDisplayMirrorEnabled, setIsDisplayMirrorEnabled ]); + + const [ isAutoCastEnabled, setIsAutoCastEnabled ] = useState(isEnabled()); + const isAutoCastSupported = supported(); + const toggleAutoCast = useCallback(() => { + enable(!isAutoCastEnabled); + setIsAutoCastEnabled(!isAutoCastEnabled); + }, [ isAutoCastEnabled, setIsAutoCastEnabled ]); + + const remotePlayerName = playerInfo?.deviceName || playerInfo?.name; + + const disconnectRemotePlayer = useCallback(() => { + if (playbackManager.getSupportedCommands().indexOf('EndSession') !== -1) { + dialog.show({ + buttons: [ + { + name: globalize.translate('Yes'), + id: 'yes' + }, { + name: globalize.translate('No'), + id: 'no' + } + ], + text: globalize.translate('ConfirmEndPlayerSession', remotePlayerName) + }).then(id => { + onMenuClose(); + + if (id === 'yes') { + playbackManager.getCurrentPlayer().endSession(); + } + playbackManager.setDefaultPlayerActive(); + }).catch(() => { + // Dialog closed + }); + } else { + onMenuClose(); + playbackManager.setDefaultPlayerActive(); + } + }, [ onMenuClose, remotePlayerName ]); + + return ( + + {remotePlayerName} + + ) + }} + > + {isDisplayMirrorSupported && ( + + {isDisplayMirrorEnabled && ( + + + + )} + + {globalize.translate('EnableDisplayMirroring')} + + + )} + + {isAutoCastSupported && ( + + {isAutoCastEnabled && ( + + + + )} + + {globalize.translate('EnableAutoCast')} + + + )} + + {(isDisplayMirrorSupported || isAutoCastSupported) && } + + + + + + + {globalize.translate('HeaderRemoteControl')} + + + + + + + + + {globalize.translate('Disconnect')} + + + + ); +}; + +export default RemotePlayActiveMenu; diff --git a/src/apps/experimental/components/AppToolbar/menus/RemotePlayMenu.tsx b/src/apps/experimental/components/AppToolbar/menus/RemotePlayMenu.tsx new file mode 100644 index 0000000000..96626eb662 --- /dev/null +++ b/src/apps/experimental/components/AppToolbar/menus/RemotePlayMenu.tsx @@ -0,0 +1,100 @@ +import Warning from '@mui/icons-material/Warning'; +import Divider from '@mui/material/Divider'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +import Menu, { type MenuProps } from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import React, { FC, useEffect, useState } from 'react'; + +import globalize from 'scripts/globalize'; +import { playbackManager } from 'components/playback/playbackmanager'; +import { pluginManager } from 'components/pluginManager'; +import type { PlayTarget } from 'types/playTarget'; + +import PlayTargetIcon from '../../PlayTargetIcon'; + +interface RemotePlayMenuProps extends MenuProps { + onMenuClose: () => void +} + +export const ID = 'app-remote-play-menu'; + +const RemotePlayMenu: FC = ({ + anchorEl, + open, + onMenuClose +}) => { + // TODO: Add other checks for support (Android app, secure context, etc) + const isChromecastPluginLoaded = !!pluginManager.plugins.find(plugin => plugin.id === 'chromecast'); + + const [ playbackTargets, setPlaybackTargets ] = useState([]); + + const onPlayTargetClick = (target: PlayTarget) => { + playbackManager.trySetActivePlayer(target.playerName, target); + onMenuClose(); + }; + + useEffect(() => { + const fetchPlaybackTargets = async () => { + setPlaybackTargets( + await playbackManager.getTargets() + ); + }; + + if (open) { + fetchPlaybackTargets() + .catch(err => { + console.error('[AppRemotePlayMenu] unable to get playback targets', err); + }); + } + }, [ open, setPlaybackTargets ]); + + return ( + + {!isChromecastPluginLoaded && ([ + + + + + + {globalize.translate('GoogleCastUnsupported')} + + , + + ])} + + {playbackTargets.map(target => ( + onPlayTargetClick(target)} + > + + + + + + ))} + + ); +}; + +export default RemotePlayMenu; diff --git a/src/apps/experimental/components/PlayTargetIcon.tsx b/src/apps/experimental/components/PlayTargetIcon.tsx new file mode 100644 index 0000000000..320f1c9f4c --- /dev/null +++ b/src/apps/experimental/components/PlayTargetIcon.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import Cast from '@mui/icons-material/Cast'; +import Computer from '@mui/icons-material/Computer'; +import Devices from '@mui/icons-material/Devices'; +import Smartphone from '@mui/icons-material/Smartphone'; +import Tablet from '@mui/icons-material/Tablet'; +import Tv from '@mui/icons-material/Tv'; + +import browser from 'scripts/browser'; +import type { PlayTarget } from 'types/playTarget'; + +const PlayTargetIcon = ({ target }: { target: PlayTarget }) => { + if (!target.deviceType && target.isLocalPlayer) { + if (browser.tv) { + return ; + } else if (browser.mobile) { + return ; + } + return ; + } + + switch (target.deviceType) { + case 'smartphone': + return ; + case 'tablet': + return ; + case 'desktop': + return ; + case 'cast': + return ; + case 'tv': + return ; + default: + return ; + } +}; + +export default PlayTargetIcon; diff --git a/src/strings/en-us.json b/src/strings/en-us.json index 67d176e350..14a547593a 100644 --- a/src/strings/en-us.json +++ b/src/strings/en-us.json @@ -215,7 +215,7 @@ "EditImages": "Edit images", "EditMetadata": "Edit metadata", "EditSubtitles": "Edit subtitles", - "EnableAutoCast": "Set as Default", + "EnableAutoCast": "Set as default", "EnableBackdropsHelp": "Display the backdrops in the background of some pages while browsing the library.", "EnableBlurHash": "Enable blurred placeholders for images", "EnableBlurHashHelp": "Images that are still being loaded will be displayed with a unique placeholder.", diff --git a/src/types/playTarget.ts b/src/types/playTarget.ts new file mode 100644 index 0000000000..33353137d5 --- /dev/null +++ b/src/types/playTarget.ts @@ -0,0 +1,11 @@ +import type { UserDto } from '@jellyfin/sdk/lib/generated-client/models/user-dto'; + +export interface PlayTarget { + id: string + name: string + appName?: string + playerName?: string + deviceType?: string + isLocalPlayer?: boolean + user?: UserDto +}