mirror of
https://github.com/chrisbenincasa/tunarr.git
synced 2026-04-18 09:03:35 -04:00
fix: improve user experience of channel overview on mobile (#1768)
* fix: improve user experience of channel overview on mobile Added: - Scrollable tabs to view media - Changed heading sizes - Scrollable tabs on program details - Fixed bullet being added to end incorrectly - Improved program info ux - Fixed an issue with `useState` inside of `useEffect` * fix: stack channel icon, name and number --------- Co-authored-by: Corey Vaillancourt <coreyjv@gmail.com>
This commit is contained in:
committed by
GitHub
parent
73954b2a26
commit
b658ddb4ab
@@ -14,7 +14,7 @@ import type { Dayjs } from 'dayjs';
|
||||
import dayjs from 'dayjs';
|
||||
import { capitalize } from 'lodash-es';
|
||||
import type { ReactEventHandler } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useSettings } from '../store/settings/selectors.ts';
|
||||
import ProgramInfoBar from './programs/ProgramInfoBar.tsx';
|
||||
|
||||
@@ -32,8 +32,20 @@ export const ProgramMetadataDialogContent = ({
|
||||
stop,
|
||||
}: Props) => {
|
||||
const settings = useSettings();
|
||||
const [thumbLoadState, setThumbLoadState] =
|
||||
useState<ThumbLoadState>('loading');
|
||||
|
||||
const thumbnailImage = useMemo(() => {
|
||||
return `${settings.backendUri}/api/programs/${program.uuid}/artwork/poster`;
|
||||
}, [settings.backendUri, program]);
|
||||
|
||||
const [imageState, setImageState] = useState<{
|
||||
url: string | undefined;
|
||||
state: ThumbLoadState;
|
||||
}>({ url: undefined, state: 'loading' });
|
||||
|
||||
// Derive load state: if the stored URL doesn't match the current thumbnail,
|
||||
// the image hasn't loaded yet — treat it as loading without a useEffect reset.
|
||||
const thumbLoadState: ThumbLoadState =
|
||||
imageState.url === thumbnailImage ? imageState.state : 'loading';
|
||||
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
const theme = useTheme();
|
||||
@@ -41,10 +53,6 @@ export const ProgramMetadataDialogContent = ({
|
||||
const isEpisode = program && program.type === 'episode';
|
||||
const imageWidth = smallViewport ? (isEpisode ? '100%' : '55%') : 240;
|
||||
|
||||
const thumbnailImage = useMemo(() => {
|
||||
return `${settings.backendUri}/api/programs/${program.uuid}/artwork/poster`;
|
||||
}, [settings.backendUri, program]);
|
||||
|
||||
const externalLink = useMemo(() => {
|
||||
return `${settings.backendUri}/api/programs/${program.uuid}/external-link`;
|
||||
}, [settings.backendUri, program]);
|
||||
@@ -53,18 +61,17 @@ export const ProgramMetadataDialogContent = ({
|
||||
return `${window.location.origin}/web/media/${program.type}/${program.uuid}`;
|
||||
}, [program]);
|
||||
|
||||
useEffect(() => {
|
||||
setThumbLoadState('loading');
|
||||
const onLoad = useCallback(() => {
|
||||
setImageState({ url: thumbnailImage, state: 'success' });
|
||||
}, [thumbnailImage]);
|
||||
|
||||
const onLoad = useCallback(() => {
|
||||
setThumbLoadState('success');
|
||||
}, [setThumbLoadState]);
|
||||
|
||||
const onError: ReactEventHandler<HTMLImageElement> = useCallback((e) => {
|
||||
console.error(e);
|
||||
setThumbLoadState('error');
|
||||
}, []);
|
||||
const onError: ReactEventHandler<HTMLImageElement> = useCallback(
|
||||
(e) => {
|
||||
console.error(e);
|
||||
setImageState({ url: thumbnailImage, state: 'error' });
|
||||
},
|
||||
[thumbnailImage],
|
||||
);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
return getProgramSummary(program);
|
||||
@@ -159,6 +166,7 @@ export const ProgramMetadataDialogContent = ({
|
||||
borderTop: `1px solid`,
|
||||
borderBottom: `1px solid`,
|
||||
my: 1,
|
||||
py: 1,
|
||||
textAlign: ['center', 'left'],
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { channelListOptions } from '@/types/index.ts';
|
||||
import { Settings } from '@mui/icons-material';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import { Button } from '@mui/material';
|
||||
import { Button, IconButton, useMediaQuery, useTheme } from '@mui/material';
|
||||
import type { Channel } from '@tunarr/types';
|
||||
import { isNull } from 'lodash-es';
|
||||
import { useState } from 'react';
|
||||
@@ -16,6 +16,8 @@ export const ChannelOptionsButton = ({ channel, hideItems }: Props) => {
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [channelMenu, setChannelMenu] = useState<Channel>();
|
||||
const open = !isNull(anchorEl);
|
||||
const theme = useTheme();
|
||||
const smallViewport = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
|
||||
const handleClick = (
|
||||
event: React.MouseEvent<HTMLElement>,
|
||||
@@ -31,19 +33,30 @@ export const ChannelOptionsButton = ({ channel, hideItems }: Props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<Settings />}
|
||||
aria-controls={open ? 'channel-nav-menu' : undefined}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open ? 'true' : undefined}
|
||||
disableRipple
|
||||
disableElevation
|
||||
onClick={(event) => handleClick(event, channel)}
|
||||
endIcon={<KeyboardArrowDownIcon />}
|
||||
>
|
||||
Options
|
||||
</Button>
|
||||
{smallViewport ? (
|
||||
<IconButton
|
||||
aria-controls={open ? 'channel-nav-menu' : undefined}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open ? 'true' : undefined}
|
||||
onClick={(event) => handleClick(event, channel)}
|
||||
>
|
||||
<Settings />
|
||||
</IconButton>
|
||||
) : (
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<Settings />}
|
||||
aria-controls={open ? 'channel-nav-menu' : undefined}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open ? 'true' : undefined}
|
||||
disableRipple
|
||||
disableElevation
|
||||
onClick={(event) => handleClick(event, channel)}
|
||||
endIcon={<KeyboardArrowDownIcon />}
|
||||
>
|
||||
Options
|
||||
</Button>
|
||||
)}
|
||||
{channelMenu && (
|
||||
<ChannelOptionsMenu
|
||||
anchorEl={anchorEl}
|
||||
|
||||
@@ -175,7 +175,12 @@ export const ChannelPrograms = ({ channelId }: Props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tabs value={tab} onChange={(_, v) => setTab(v as number)}>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={(_, v) => setTab(v as number)}
|
||||
variant="scrollable"
|
||||
allowScrollButtonsMobile
|
||||
>
|
||||
{Object.values(ContentProgramTypeSchema.enum).map((v, idx) => (
|
||||
<ProgramTypeTab
|
||||
key={v}
|
||||
|
||||
@@ -79,7 +79,7 @@ export const ChannelSummaryQuickStats = ({ channelId }: Props) => {
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Grid size={{ xs: 12, md: 4 }} sx={{ p: 1 }}>
|
||||
<Grid size={{ xs: 12, md: 4 }} sx={{ p: [0.5, 1] }}>
|
||||
<Stack direction="row">
|
||||
<div>
|
||||
<Typography variant="overline">Total Runtime</Typography>
|
||||
@@ -88,7 +88,7 @@ export const ChannelSummaryQuickStats = ({ channelId }: Props) => {
|
||||
<Box></Box>
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 4 }} sx={{ p: 1 }}>
|
||||
<Grid size={{ xs: 12, md: 4 }} sx={{ p: [0.5, 1] }}>
|
||||
<Stack direction="row">
|
||||
<div>
|
||||
<Typography variant="overline">Programs</Typography>
|
||||
@@ -96,7 +96,7 @@ export const ChannelSummaryQuickStats = ({ channelId }: Props) => {
|
||||
</div>
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 2 }} sx={{ p: 1 }}>
|
||||
<Grid size={{ xs: 12, md: 2 }} sx={{ p: [0.5, 1] }}>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="overline">Stream Mode</Typography>
|
||||
<Typography variant="h5">
|
||||
@@ -104,7 +104,7 @@ export const ChannelSummaryQuickStats = ({ channelId }: Props) => {
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 2 }} sx={{ p: 1 }}>
|
||||
<Grid size={{ xs: 12, md: 2 }} sx={{ p: [0.5, 1] }}>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="overline">
|
||||
Transcode Config{' '}
|
||||
|
||||
@@ -110,10 +110,20 @@ function ProgramDetailsDialogContent({
|
||||
</Skeleton>
|
||||
) : (
|
||||
<DialogTitle
|
||||
variant="h4"
|
||||
variant={smallViewport ? 'h6' : 'h4'}
|
||||
sx={{ display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<Box sx={{ flex: 1 }}>{title}</Box>
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Box>
|
||||
|
||||
<IconButton
|
||||
onClick={(e) => setMoreMenuAnchorEl(e.currentTarget)}
|
||||
@@ -149,6 +159,8 @@ function ProgramDetailsDialogContent({
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={(_, v) => setTab(v as Panels)}
|
||||
variant="scrollable"
|
||||
allowScrollButtonsMobile
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
{visibility.metadata && <Tab value={'metadata'} label="Overview" />}
|
||||
@@ -332,7 +344,11 @@ export default function ProgramDetailsDialog(props: Props) {
|
||||
backgroundSize: 'cover',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'center',
|
||||
minHeight: programType === 'episode' ? 450 : 575,
|
||||
minHeight: smallViewport
|
||||
? undefined
|
||||
: programType === 'episode'
|
||||
? 450
|
||||
: 575,
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
@@ -193,12 +193,33 @@ export default function ProgramInfoBar({ program, time }: Props) {
|
||||
seasonTitle,
|
||||
]);
|
||||
|
||||
return compact(itemInfoBar).map((chip, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<Box display="inline-block">{chip}</Box>
|
||||
{index < itemInfoBar.length - 1 && (
|
||||
<Box display="inline-block"> • </Box>
|
||||
)}
|
||||
</React.Fragment>
|
||||
));
|
||||
const compacted = compact(itemInfoBar);
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
rowGap: 0.5,
|
||||
}}
|
||||
>
|
||||
{compacted.map((chip, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>{chip}</Box>
|
||||
{index < compacted.length - 1 && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
mx: 0.75,
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
•
|
||||
</Box>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,21 +29,21 @@ export const ChannelSummaryPage = () => {
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Breadcrumbs />
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<Box>
|
||||
{isNonEmptyString(channel.icon.path) ? (
|
||||
<Box component="img" width={[32, 132]} src={channel.icon.path} />
|
||||
) : (
|
||||
<TunarrLogo style={{ width: smallViewport ? '32px' : '132px' }} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="h4">{channel.name}</Typography>
|
||||
<Stack direction="row" alignItems="flex-start" spacing={1}>
|
||||
<Stack spacing={0.5} sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Box>
|
||||
{isNonEmptyString(channel.icon.path) ? (
|
||||
<Box component="img" width={[48, 132]} src={channel.icon.path} />
|
||||
) : (
|
||||
<TunarrLogo style={{ width: smallViewport ? '48px' : '132px' }} />
|
||||
)}
|
||||
</Box>
|
||||
<Typography variant={smallViewport ? 'h5' : 'h4'} noWrap>
|
||||
{channel.name}
|
||||
</Typography>
|
||||
<Typography variant="subtitle1">Channel #{channel.number}</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={1} justifyContent="right">
|
||||
</Stack>
|
||||
|
||||
<ChannelOptionsButton
|
||||
channel={channel}
|
||||
hideItems={['duplicate', 'delete']}
|
||||
|
||||
Reference in New Issue
Block a user