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:
Corey Vaillancourt
2026-03-30 19:45:07 -04:00
committed by GitHub
parent 73954b2a26
commit b658ddb4ab
7 changed files with 124 additions and 61 deletions

View File

@@ -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'],
}}
>

View File

@@ -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}

View File

@@ -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}

View File

@@ -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{' '}

View File

@@ -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,
},
},
}}

View File

@@ -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">&nbsp;&nbsp;&bull;&nbsp;&nbsp;</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',
}}
>
&bull;
</Box>
)}
</React.Fragment>
))}
</Box>
);
}

View File

@@ -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']}