diff --git a/src/apps/experimental/components/AppToolbar/SyncPlayButton.tsx b/src/apps/experimental/components/AppToolbar/SyncPlayButton.tsx new file mode 100644 index 0000000000..94a0834876 --- /dev/null +++ b/src/apps/experimental/components/AppToolbar/SyncPlayButton.tsx @@ -0,0 +1,61 @@ +import { SyncPlayUserAccessType } from '@jellyfin/sdk/lib/generated-client/models/sync-play-user-access-type'; +import Groups from '@mui/icons-material/Groups'; +import IconButton from '@mui/material/IconButton'; +import Tooltip from '@mui/material/Tooltip'; +import React, { useCallback, useState } from 'react'; + +import { pluginManager } from 'components/pluginManager'; +import { useApi } from 'hooks/useApi'; +import globalize from 'scripts/globalize'; +import { PluginType } from 'types/plugin'; + +import AppSyncPlayMenu, { ID } from './menus/SyncPlayMenu'; + +const SyncPlayButton = () => { + const { user } = useApi(); + + const [ syncPlayMenuAnchorEl, setSyncPlayMenuAnchorEl ] = useState(null); + const isSyncPlayMenuOpen = Boolean(syncPlayMenuAnchorEl); + + const onSyncPlayButtonClick = useCallback((event) => { + setSyncPlayMenuAnchorEl(event.currentTarget); + }, [ setSyncPlayMenuAnchorEl ]); + + const onSyncPlayMenuClose = useCallback(() => { + setSyncPlayMenuAnchorEl(null); + }, [ setSyncPlayMenuAnchorEl ]); + + if ( + // SyncPlay not enabled for user + (user?.Policy && user.Policy.SyncPlayAccess === SyncPlayUserAccessType.None) + // SyncPlay plugin is not loaded + || pluginManager.ofType(PluginType.SyncPlay).length === 0 + ) { + return null; + } + + return ( + <> + + + + + + + + + ); +}; + +export default SyncPlayButton; diff --git a/src/apps/experimental/components/AppToolbar/index.tsx b/src/apps/experimental/components/AppToolbar/index.tsx index 433fa4c836..8d87961511 100644 --- a/src/apps/experimental/components/AppToolbar/index.tsx +++ b/src/apps/experimental/components/AppToolbar/index.tsx @@ -16,6 +16,7 @@ import AppTabs from '../tabs/AppTabs'; import { isDrawerPath } from '../drawers/AppDrawer'; import UserMenuButton from './UserMenuButton'; import RemotePlayButton from './RemotePlayButton'; +import SyncPlayButton from './SyncPlayButton'; interface AppToolbarProps { isDrawerOpen: boolean @@ -90,6 +91,7 @@ const AppToolbar: FC = ({ {isUserLoggedIn && ( <> + diff --git a/src/apps/experimental/components/AppToolbar/menus/SyncPlayMenu.tsx b/src/apps/experimental/components/AppToolbar/menus/SyncPlayMenu.tsx new file mode 100644 index 0000000000..cb377784e8 --- /dev/null +++ b/src/apps/experimental/components/AppToolbar/menus/SyncPlayMenu.tsx @@ -0,0 +1,308 @@ +import type { GroupInfoDto } from '@jellyfin/sdk/lib/generated-client/models/group-info-dto'; +import { SyncPlayUserAccessType } from '@jellyfin/sdk/lib/generated-client/models/sync-play-user-access-type'; +import { getSyncPlayApi } from '@jellyfin/sdk/lib/utils/api/sync-play-api'; +import GroupAdd from '@mui/icons-material/GroupAdd'; +import PersonAdd from '@mui/icons-material/PersonAdd'; +import PersonOff from '@mui/icons-material/PersonOff'; +import PersonRemove from '@mui/icons-material/PersonRemove'; +import PlayCircle from '@mui/icons-material/PlayCircle'; +import StopCircle from '@mui/icons-material/StopCircle'; +import Tune from '@mui/icons-material/Tune'; +import Divider from '@mui/material/Divider'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +import ListSubheader from '@mui/material/ListSubheader'; +import Menu, { MenuProps } from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import type { ApiClient } from 'jellyfin-apiclient'; +import React, { FC, useCallback, useEffect, useState } from 'react'; + +import { pluginManager } from 'components/pluginManager'; +import { useApi } from 'hooks/useApi'; +import globalize from 'scripts/globalize'; +import { PluginType } from 'types/plugin'; +import Events from 'utils/events'; + +export const ID = 'app-sync-play-menu'; + +interface SyncPlayMenuProps extends MenuProps { + onMenuClose: () => void +} + +interface SyncPlayInstance { + Manager: { + getGroupInfo: () => GroupInfoDto | null | undefined + getTimeSyncCore: () => object + isPlaybackActive: () => boolean + isPlaylistEmpty: () => boolean + haltGroupPlayback: (apiClient: ApiClient) => void + resumeGroupPlayback: (apiClient: ApiClient) => void + } +} + +const SyncPlayMenu: FC = ({ + anchorEl, + open, + onMenuClose +}) => { + const [ syncPlay, setSyncPlay ] = useState(); + const { __legacyApiClient__, api, user } = useApi(); + const [ groups, setGroups ] = useState([]); + const [ currentGroup, setCurrentGroup ] = useState(); + const isSyncPlayEnabled = Boolean(currentGroup); + + useEffect(() => { + setSyncPlay(pluginManager.firstOfType(PluginType.SyncPlay)?.instance); + }, []); + + useEffect(() => { + const fetchGroups = async () => { + if (api) { + setGroups((await getSyncPlayApi(api).syncPlayGetGroups()).data); + } + }; + + fetchGroups() + .catch(err => { + console.error('[SyncPlayMenu] unable to fetch SyncPlay groups', err); + }); + }, [ api ]); + + const onGroupAddClick = useCallback(() => { + if (api && user) { + getSyncPlayApi(api) + .syncPlayCreateGroup({ + newGroupRequestDto: { + GroupName: globalize.translate('SyncPlayGroupDefaultTitle', user.Name) + } + }) + .catch(err => { + console.error('[SyncPlayMenu] failed to create a SyncPlay group', err); + }); + + onMenuClose(); + } + }, [ api, onMenuClose, user ]); + + const onGroupLeaveClick = useCallback(() => { + if (api) { + getSyncPlayApi(api) + .syncPlayLeaveGroup() + .catch(err => { + console.error('[SyncPlayMenu] failed to leave SyncPlay group', err); + }); + + onMenuClose(); + } + }, [ api, onMenuClose ]); + + const onGroupJoinClick = useCallback((GroupId: string) => { + if (api) { + getSyncPlayApi(api) + .syncPlayJoinGroup({ + joinGroupRequestDto: { + GroupId + } + }) + .catch(err => { + console.error('[SyncPlayMenu] failed to join SyncPlay group', err); + }); + + onMenuClose(); + } + }, [ api, onMenuClose ]); + + const onGroupSettingsClick = useCallback(async () => { + if (!syncPlay) return; + + // TODO: Rewrite settings UI + const SyncPlaySettingsEditor = (await import('../../../../../plugins/syncPlay/ui/settings/SettingsEditor')).default; + new SyncPlaySettingsEditor( + __legacyApiClient__, + syncPlay.Manager.getTimeSyncCore(), + { + groupInfo: currentGroup + }) + .embed() + .catch(err => { + if (err) { + console.error('[SyncPlayMenu] Error creating SyncPlay settings editor', err); + } + }); + + onMenuClose(); + }, [ __legacyApiClient__, currentGroup, onMenuClose, syncPlay ]); + + const onStartGroupPlaybackClick = useCallback(() => { + if (__legacyApiClient__) { + syncPlay?.Manager.resumeGroupPlayback(__legacyApiClient__); + onMenuClose(); + } + }, [ __legacyApiClient__, onMenuClose, syncPlay ]); + + const onStopGroupPlaybackClick = useCallback(() => { + if (__legacyApiClient__) { + syncPlay?.Manager.haltGroupPlayback(__legacyApiClient__); + onMenuClose(); + } + }, [ __legacyApiClient__, onMenuClose, syncPlay ]); + + const updateSyncPlayGroup = useCallback((_e, enabled) => { + if (syncPlay && enabled) { + setCurrentGroup(syncPlay.Manager.getGroupInfo() ?? undefined); + } else { + setCurrentGroup(undefined); + } + }, [ syncPlay ]); + + useEffect(() => { + if (!syncPlay) return; + + Events.on(syncPlay.Manager, 'enabled', updateSyncPlayGroup); + + return () => { + Events.off(syncPlay.Manager, 'enabled', updateSyncPlayGroup); + }; + }, [ updateSyncPlayGroup, syncPlay ]); + + const menuItems = []; + if (isSyncPlayEnabled) { + if (!syncPlay?.Manager.isPlaylistEmpty() && !syncPlay?.Manager.isPlaybackActive()) { + menuItems.push( + + + + + + + ); + } else if (syncPlay?.Manager.isPlaybackActive()) { + menuItems.push( + + + + + + + ); + } + + menuItems.push( + + + + + + + ); + + menuItems.push( + + ); + + menuItems.push( + + + + + + + ); + } else if (groups.length === 0 && user?.Policy?.SyncPlayAccess !== SyncPlayUserAccessType.CreateAndJoinGroups) { + menuItems.push( + + + + + + + ); + } else { + if (groups.length > 0) { + groups.forEach(group => { + menuItems.push( + group.GroupId && onGroupJoinClick(group.GroupId)} + > + + + + + + ); + }); + + menuItems.push( + + ); + } + + if (user?.Policy?.SyncPlayAccess === SyncPlayUserAccessType.CreateAndJoinGroups) { + menuItems.push( + + + + + + + ); + } + } + + const MenuListProps = isSyncPlayEnabled ? { + 'aria-labelledby': 'sync-play-active-subheader', + subheader: ( + + {currentGroup?.GroupName} + + ) + } : undefined; + + return ( + + {menuItems} + + ); +}; + +export default SyncPlayMenu; diff --git a/src/strings/en-us.json b/src/strings/en-us.json index 5cc2db0ca6..d423c2ce32 100644 --- a/src/strings/en-us.json +++ b/src/strings/en-us.json @@ -905,6 +905,7 @@ "LabelSyncPlayLeaveGroupDescription": "Disable SyncPlay", "LabelSyncPlayNewGroup": "New group", "LabelSyncPlayNewGroupDescription": "Create a new group", + "LabelSyncPlayNoGroups": "No groups available", "LabelSyncPlayPlaybackDiff": "Playback time difference", "LabelSyncPlayResumePlayback": "Resume local playback", "LabelSyncPlayResumePlaybackDescription": "Join back group playback",