1
0
Fork 0
mirror of https://github.com/jellyfin/jellyfin-web synced 2025-03-30 19:56:21 +00:00

Merge branch 'master' into trickplay-new

This commit is contained in:
Nick 2024-03-08 21:47:06 -08:00 committed by GitHub
commit 2dfc0aa061
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
137 changed files with 5909 additions and 2829 deletions

View file

@ -0,0 +1,92 @@
import React, { type FC, useCallback, useEffect, useState } from 'react';
import Events, { Event } from 'utils/events';
import serverNotifications from 'scripts/serverNotifications';
import classNames from 'classnames';
import CircularProgress, {
CircularProgressProps
} from '@mui/material/CircularProgress';
import Typography from '@mui/material/Typography';
import Box from '@mui/material/Box';
import { toPercent } from 'utils/number';
import { getCurrentDateTimeLocale } from 'scripts/globalize';
import type { ItemDto } from 'types/base/models/item-dto';
function CircularProgressWithLabel(
props: CircularProgressProps & { value: number }
) {
return (
<Box sx={{ position: 'relative', display: 'inline-flex' }}>
<CircularProgress variant='determinate' {...props} />
<Box
sx={{
top: 0,
left: 0,
bottom: 0,
right: 0,
position: 'absolute',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<Typography
variant='caption'
component='div'
color='text.secondary'
>
{toPercent(props.value / 100, getCurrentDateTimeLocale())}
</Typography>
</Box>
</Box>
);
}
interface RefreshIndicatorProps {
item: ItemDto;
className?: string;
}
const RefreshIndicator: FC<RefreshIndicatorProps> = ({ item, className }) => {
const [progress, setProgress] = useState(item.RefreshProgress || 0);
const onRefreshProgress = useCallback((_e: Event, apiClient, info) => {
if (info.ItemId === item?.Id) {
setProgress(parseFloat(info.Progress));
}
}, [item?.Id]);
const unbindEvents = useCallback(() => {
Events.off(serverNotifications, 'RefreshProgress', onRefreshProgress);
}, [onRefreshProgress]);
const bindEvents = useCallback(() => {
unbindEvents();
if (item?.Id) {
Events.on(serverNotifications, 'RefreshProgress', onRefreshProgress);
}
}, [item?.Id, onRefreshProgress, unbindEvents]);
useEffect(() => {
bindEvents();
return () => {
unbindEvents();
};
}, [bindEvents, item.Id, unbindEvents]);
const progressringClass = classNames(
'progressring',
className,
{ 'hide': !progress || progress >= 100 }
);
return (
<div className={progressringClass}>
<CircularProgressWithLabel value={Math.floor(progress)} />
</div>
);
};
export default RefreshIndicator;

View file

@ -1,13 +1,11 @@
import type {
LibraryUpdateInfo,
SeriesTimerInfoDto,
TimerInfoDto,
UserItemDataDto
LibraryUpdateInfo
} from '@jellyfin/sdk/lib/generated-client';
import React, { FC, useCallback, useEffect, useRef } from 'react';
import React, { type FC, useCallback, useEffect, useRef } from 'react';
import classNames from 'classnames';
import { Box } from '@mui/material';
import Box from '@mui/material/Box';
import Sortable from 'sortablejs';
import { useQueryClient } from '@tanstack/react-query';
import { usePlaylistsMoveItemMutation } from 'hooks/useFetchItems';
import Events, { Event } from 'utils/events';
import serverNotifications from 'scripts/serverNotifications';
@ -21,7 +19,7 @@ import itemShortcuts from 'components/shortcuts';
import MultiSelect from 'components/multiSelect/multiSelect';
import loading from 'components/loading/loading';
import focusManager from 'components/focusManager';
import { ParentId } from 'types/library';
import type { ParentId } from 'types/library';
function disableEvent(e: MouseEvent) {
e.preventDefault();
@ -40,11 +38,11 @@ interface ItemsContainerProps {
isContextMenuEnabled?: boolean;
isMultiSelectEnabled?: boolean;
isDragreOrderEnabled?: boolean;
dataMonitor?: string;
eventsToMonitor?: string[];
parentId?: ParentId;
reloadItems?: () => void;
getItemsHtml?: () => string;
children?: React.ReactNode;
queryKey?: string[]
}
const ItemsContainer: FC<ItemsContainerProps> = ({
@ -52,12 +50,14 @@ const ItemsContainer: FC<ItemsContainerProps> = ({
isContextMenuEnabled,
isMultiSelectEnabled,
isDragreOrderEnabled,
dataMonitor,
eventsToMonitor = [],
parentId,
queryKey,
reloadItems,
getItemsHtml,
children
}) => {
const queryClient = useQueryClient();
const { mutateAsync: playlistsMoveItemMutation } = usePlaylistsMoveItemMutation();
const itemsContainerRef = useRef<HTMLDivElement>(null);
const multiSelectref = useRef<MultiSelect | null>(null);
@ -172,6 +172,14 @@ const ItemsContainer: FC<ItemsContainerProps> = ({
}
}, []);
const invalidateQueries = useCallback(async () => {
await queryClient.invalidateQueries({
queryKey,
type: 'all',
refetchType: 'active'
});
}, [queryClient, queryKey]);
const notifyRefreshNeeded = useCallback(
(isInForeground: boolean) => {
if (!reloadItems) return;
@ -184,144 +192,37 @@ const ItemsContainer: FC<ItemsContainerProps> = ({
[reloadItems]
);
const getEventsToMonitor = useCallback(() => {
const monitor = dataMonitor;
if (monitor) {
return monitor.split(',');
}
return [];
}, [dataMonitor]);
const onUserDataChanged = useCallback(
(_e: Event, userData: UserItemDataDto) => {
const itemsContainer = itemsContainerRef.current as HTMLDivElement;
import('../../components/cardbuilder/cardBuilder')
.then((cardBuilder) => {
cardBuilder.onUserDataChanged(userData, itemsContainer);
})
.catch((err) => {
console.error(
'[onUserDataChanged] failed to load onUserData Changed',
err
);
});
const eventsToMonitor = getEventsToMonitor();
if (
eventsToMonitor.indexOf('markfavorite') !== -1
|| eventsToMonitor.indexOf('markplayed') !== -1
) {
notifyRefreshNeeded(false);
}
},
[getEventsToMonitor, notifyRefreshNeeded]
const onUserDataChanged = useCallback(async () => {
await invalidateQueries();
},
[invalidateQueries]
);
const onTimerCreated = useCallback(
(_e: Event, data: TimerInfoDto) => {
const itemsContainer = itemsContainerRef.current as HTMLDivElement;
const eventsToMonitor = getEventsToMonitor();
if (eventsToMonitor.indexOf('timers') !== -1) {
notifyRefreshNeeded(false);
return;
}
const programId = data.ProgramId;
// This could be null, not supported by all tv providers
const newTimerId = data.Id;
if (programId && newTimerId) {
import('../../components/cardbuilder/cardBuilder')
.then((cardBuilder) => {
cardBuilder.onTimerCreated(
programId,
newTimerId,
itemsContainer
);
})
.catch((err) => {
console.error(
'[onTimerCreated] failed to load onTimer Created',
err
);
});
}
},
[getEventsToMonitor, notifyRefreshNeeded]
const onTimerCreated = useCallback(async () => {
await invalidateQueries();
},
[invalidateQueries]
);
const onSeriesTimerCreated = useCallback(() => {
const eventsToMonitor = getEventsToMonitor();
if (eventsToMonitor.indexOf('seriestimers') !== -1) {
notifyRefreshNeeded(false);
}
}, [getEventsToMonitor, notifyRefreshNeeded]);
const onSeriesTimerCreated = useCallback(async () => {
await invalidateQueries();
}, [invalidateQueries]);
const onTimerCancelled = useCallback(
(_e: Event, data: TimerInfoDto) => {
const itemsContainer = itemsContainerRef.current as HTMLDivElement;
const eventsToMonitor = getEventsToMonitor();
if (eventsToMonitor.indexOf('timers') !== -1) {
notifyRefreshNeeded(false);
return;
}
const timerId = data.Id;
if (timerId) {
import('../../components/cardbuilder/cardBuilder')
.then((cardBuilder) => {
cardBuilder.onTimerCancelled(timerId, itemsContainer);
})
.catch((err) => {
console.error(
'[onTimerCancelled] failed to load onTimer Cancelled',
err
);
});
}
},
[getEventsToMonitor, notifyRefreshNeeded]
const onTimerCancelled = useCallback(async () => {
await invalidateQueries();
},
[invalidateQueries]
);
const onSeriesTimerCancelled = useCallback(
(_e: Event, data: SeriesTimerInfoDto) => {
const itemsContainer = itemsContainerRef.current as HTMLDivElement;
const eventsToMonitor = getEventsToMonitor();
if (eventsToMonitor.indexOf('seriestimers') !== -1) {
notifyRefreshNeeded(false);
return;
}
const cancelledTimerId = data.Id;
if (cancelledTimerId) {
import('../../components/cardbuilder/cardBuilder')
.then((cardBuilder) => {
cardBuilder.onSeriesTimerCancelled(
cancelledTimerId,
itemsContainer
);
})
.catch((err) => {
console.error(
'[onSeriesTimerCancelled] failed to load onSeriesTimer Cancelled',
err
);
});
}
},
[getEventsToMonitor, notifyRefreshNeeded]
const onSeriesTimerCancelled = useCallback(async () => {
await invalidateQueries();
},
[invalidateQueries]
);
const onLibraryChanged = useCallback(
(_e: Event, data: LibraryUpdateInfo) => {
const eventsToMonitor = getEventsToMonitor();
if (
eventsToMonitor.indexOf('seriestimers') !== -1
|| eventsToMonitor.indexOf('timers') !== -1
) {
(_e: Event, apiClient, data: LibraryUpdateInfo) => {
if (eventsToMonitor.includes('seriestimers') || eventsToMonitor.includes('timers')) {
// yes this is an assumption
return;
}
@ -348,32 +249,31 @@ const ItemsContainer: FC<ItemsContainerProps> = ({
notifyRefreshNeeded(false);
},
[getEventsToMonitor, notifyRefreshNeeded, parentId]
[eventsToMonitor, notifyRefreshNeeded, parentId]
);
const onPlaybackStopped = useCallback(
(_e: Event, stopInfo) => {
(_e: Event, apiClient, stopInfo) => {
const state = stopInfo.state;
const eventsToMonitor = getEventsToMonitor();
if (
state.NowPlayingItem
&& state.NowPlayingItem.MediaType === 'Video'
) {
if (eventsToMonitor.indexOf('videoplayback') !== -1) {
if (eventsToMonitor.includes('videoplayback')) {
notifyRefreshNeeded(true);
return;
}
} else if (
state.NowPlayingItem
&& state.NowPlayingItem.MediaType === 'Audio'
&& eventsToMonitor.indexOf('audioplayback') !== -1
&& eventsToMonitor.includes('videoplayback')
) {
notifyRefreshNeeded(true);
return;
}
},
[getEventsToMonitor, notifyRefreshNeeded]
[eventsToMonitor, notifyRefreshNeeded]
);
const setFocus = useCallback(
@ -418,10 +318,9 @@ const ItemsContainer: FC<ItemsContainerProps> = ({
if (getItemsHtml) {
itemsContainer.innerHTML = getItemsHtml();
imageLoader.lazyChildren(itemsContainer);
}
imageLoader.lazyChildren(itemsContainer);
if (hasActiveElement) {
setFocus(itemsContainer, focusId);
}

View file

@ -1,5 +1,6 @@
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client';
import React, { FC, useCallback } from 'react';
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind';
import { useQueryClient } from '@tanstack/react-query';
import React, { type FC, useCallback } from 'react';
import CheckIcon from '@mui/icons-material/Check';
import { IconButton } from '@mui/material';
import classNames from 'classnames';
@ -10,28 +11,30 @@ interface PlayedButtonProps {
className?: string;
isPlayed : boolean | undefined;
itemId: string | null | undefined;
itemType: string | null | undefined
itemType: string | null | undefined,
queryKey?: string[]
}
const PlayedButton: FC<PlayedButtonProps> = ({
className,
isPlayed = false,
itemId,
itemType
itemType,
queryKey
}) => {
const queryClient = useQueryClient();
const { mutateAsync: togglePlayedMutation } = useTogglePlayedMutation();
const [playedState, setPlayedState] = React.useState<boolean>(isPlayed);
const getTitle = useCallback(() => {
let buttonTitle;
if (itemType !== BaseItemKind.AudioBook) {
buttonTitle = playedState ? globalize.translate('Watched') : globalize.translate('MarkPlayed');
buttonTitle = isPlayed ? globalize.translate('Watched') : globalize.translate('MarkPlayed');
} else {
buttonTitle = playedState ? globalize.translate('Played') : globalize.translate('MarkPlayed');
buttonTitle = isPlayed ? globalize.translate('Played') : globalize.translate('MarkPlayed');
}
return buttonTitle;
}, [playedState, itemType]);
}, [itemType, isPlayed]);
const onClick = useCallback(async () => {
try {
@ -39,23 +42,29 @@ const PlayedButton: FC<PlayedButtonProps> = ({
throw new Error('Item has no Id');
}
const _isPlayed = await togglePlayedMutation({
await togglePlayedMutation({
itemId,
playedState
});
setPlayedState(!!_isPlayed);
isPlayed
},
{ onSuccess: async() => {
await queryClient.invalidateQueries({
queryKey,
type: 'all',
refetchType: 'active'
});
} });
} catch (e) {
console.error(e);
}
}, [playedState, itemId, togglePlayedMutation]);
}, [itemId, togglePlayedMutation, isPlayed, queryClient, queryKey]);
const btnClass = classNames(
className,
{ 'playstatebutton-played': playedState }
{ 'playstatebutton-played': isPlayed }
);
const iconClass = classNames(
{ 'playstatebutton-icon-played': playedState }
{ 'playstatebutton-icon-played': isPlayed }
);
return (
<IconButton

View file

@ -0,0 +1,79 @@
import React, { type FC, useCallback, useEffect, useRef, useState } from 'react';
import classNames from 'classnames';
import LinearProgress, { linearProgressClasses } from '@mui/material/LinearProgress';
import { useTheme } from '@mui/material/styles';
import type { ProgressOptions } from 'types/progressOptions';
interface AutoTimeProgressBarProps {
pct: number;
starTtime: number;
endTtime: number;
isRecording: boolean;
dataAutoMode?: string;
progressOptions?: ProgressOptions;
}
const AutoTimeProgressBar: FC<AutoTimeProgressBarProps> = ({
pct,
dataAutoMode,
isRecording,
starTtime,
endTtime,
progressOptions
}) => {
const [progress, setProgress] = useState(pct);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const theme = useTheme();
const onAutoTimeProgress = useCallback(() => {
const start = parseInt(starTtime.toString(), 10);
const end = parseInt(endTtime.toString(), 10);
const now = new Date().getTime();
const total = end - start;
let percentage = 100 * ((now - start) / total);
percentage = Math.min(100, percentage);
percentage = Math.max(0, percentage);
setProgress(percentage);
}, [endTtime, starTtime]);
useEffect(() => {
if (timerRef.current) {
clearInterval(timerRef.current);
}
if (dataAutoMode === 'time') {
timerRef.current = setInterval(onAutoTimeProgress, 60000);
}
return () => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
};
}, [dataAutoMode, onAutoTimeProgress]);
const progressBarClass = classNames(
'itemLinearProgress',
progressOptions?.containerClass
);
return (
<LinearProgress
className={progressBarClass}
variant='determinate'
value={progress}
sx={{
[`& .${linearProgressClasses.bar}`]: {
borderRadius: 5,
backgroundColor: isRecording ? theme.palette.error.main : theme.palette.primary.main
}
}}
/>
);
};
export default AutoTimeProgressBar;

View file

@ -1,4 +1,5 @@
import React, { FC, useCallback } from 'react';
import React, { type FC, useCallback } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import FavoriteIcon from '@mui/icons-material/Favorite';
import { IconButton } from '@mui/material';
import classNames from 'classnames';
@ -8,16 +9,18 @@ import globalize from 'scripts/globalize';
interface FavoriteButtonProps {
className?: string;
isFavorite: boolean | undefined;
itemId: string | null | undefined
itemId: string | null | undefined;
queryKey?: string[]
}
const FavoriteButton: FC<FavoriteButtonProps> = ({
className,
isFavorite = false,
itemId
itemId,
queryKey
}) => {
const queryClient = useQueryClient();
const { mutateAsync: toggleFavoriteMutation } = useToggleFavoriteMutation();
const [favoriteState, setFavoriteState] = React.useState<boolean>(isFavorite);
const onClick = useCallback(async () => {
try {
@ -25,28 +28,34 @@ const FavoriteButton: FC<FavoriteButtonProps> = ({
throw new Error('Item has no Id');
}
const _isFavorite = await toggleFavoriteMutation({
await toggleFavoriteMutation({
itemId,
favoriteState
});
setFavoriteState(!!_isFavorite);
isFavorite
},
{ onSuccess: async() => {
await queryClient.invalidateQueries({
queryKey,
type: 'all',
refetchType: 'active'
});
} });
} catch (e) {
console.error(e);
}
}, [favoriteState, itemId, toggleFavoriteMutation]);
}, [isFavorite, itemId, queryClient, queryKey, toggleFavoriteMutation]);
const btnClass = classNames(
className,
{ 'ratingbutton-withrating': favoriteState }
{ 'ratingbutton-withrating': isFavorite }
);
const iconClass = classNames(
{ 'ratingbutton-icon-withrating': favoriteState }
{ 'ratingbutton-icon-withrating': isFavorite }
);
return (
<IconButton
title={favoriteState ? globalize.translate('Favorite') : globalize.translate('AddToFavorites')}
title={isFavorite ? globalize.translate('Favorite') : globalize.translate('AddToFavorites')}
className={btnClass}
size='small'
onClick={onClick}

View file

@ -1,4 +1,4 @@
import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
import React, { type FC, useCallback, useEffect, useRef, useState } from 'react';
import scrollerFactory from '../../libraries/scroller';
import globalize from '../../scripts/globalize';
import IconButton from '../emby-button/IconButton';

View file

@ -1,4 +1,4 @@
import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
import React, { type FC, useCallback, useEffect, useRef, useState } from 'react';
import classNames from 'classnames';
import useElementSize from 'hooks/useElementSize';
import layoutManager from '../../components/layoutManager';