refactor(epg/guide): streamline season/episode extraction and guide card layout

- Consolidate serializer season/episode parsing into a single to_representation
  pass: one custom_properties read, regex runs at most once per program
- Compact card: S/E badge inline with title; secondary row shows sub_title with
  description fallback
- Expanded card: sub_title and S/E badge share a row above the description
- Fix secondary text color for legibility on dark backgrounds (#a0aec0 → #e2e8f0)
- Fix expanded card overflow (height:100% → flex:1 1 0) so action buttons stay visible
- Bump expanded card height 180 → 200px, description max-height 80 → 100px
This commit is contained in:
SergeantPanda 2026-03-06 10:44:11 -06:00
parent ee112de84a
commit fa0a963ad9
3 changed files with 74 additions and 52 deletions

View file

@ -83,38 +83,26 @@ class EPGSourceSerializer(serializers.ModelSerializer):
return instance
class ProgramDataSerializer(serializers.ModelSerializer):
season = serializers.SerializerMethodField()
episode = serializers.SerializerMethodField()
class Meta:
model = ProgramData
fields = ['id', 'start_time', 'end_time', 'title', 'sub_title', 'description', 'tvg_id', 'season', 'episode']
fields = ['id', 'start_time', 'end_time', 'title', 'sub_title', 'description', 'tvg_id']
def _parse_onscreen(self, obj):
"""Parse season/episode from onscreen_episode string (e.g. 'S12 E6')."""
onscreen = (obj.custom_properties or {}).get('onscreen_episode', '')
match = _ONSCREEN_RE.search(onscreen)
if match:
return int(match.group(1)), int(match.group(2))
return None, None
def get_season(self, obj):
if obj.custom_properties:
season = obj.custom_properties.get('season')
if season is not None:
return season
season, _ = self._parse_onscreen(obj)
return season
return None
def get_episode(self, obj):
if obj.custom_properties:
episode = obj.custom_properties.get('episode')
if episode is not None:
return episode
_, episode = self._parse_onscreen(obj)
return episode
return None
def to_representation(self, obj):
data = super().to_representation(obj)
cp = obj.custom_properties or {}
season = cp.get('season')
episode = cp.get('episode')
# Only run the regex if at least one value is still missing
if (season is None or episode is None) and cp.get('onscreen_episode'):
match = _ONSCREEN_RE.search(cp['onscreen_episode'])
if match:
if season is None:
season = int(match.group(1))
if episode is None:
episode = int(match.group(2))
data['season'] = season
data['episode'] = episode
return data
class EPGDataSerializer(serializers.ModelSerializer):
"""

View file

@ -966,8 +966,13 @@ export default function TVChannelGuide({ startDate, endDate }) {
transition: 'transform 0.1s ease-out',
display: 'flex',
flexDirection: 'column',
justifyContent: !isExpanded && (program.sub_title || seasonEpisodeLabel) ? 'space-between' : 'flex-start',
height: '100%',
justifyContent:
!isExpanded && (program.sub_title || program.description)
? 'space-between'
: 'flex-start',
flex: '1 1 0',
minHeight: 0,
overflow: 'hidden',
}}
>
<Text
@ -994,13 +999,49 @@ export default function TVChannelGuide({ startDate, endDate }) {
></div>
)}
{program.title}
{!isExpanded && seasonEpisodeLabel && (
<Badge
size="xs"
variant="light"
color="gray"
style={{ flexShrink: 0 }}
>
{seasonEpisodeLabel}
</Badge>
)}
</Group>
</Text>
{(program.sub_title || seasonEpisodeLabel) && (
{!isExpanded && (program.sub_title || program.description) && (
<Text
size="xs"
fs={program.sub_title ? 'italic' : 'normal'}
style={{
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
overflow: 'hidden',
minWidth: 0,
}}
c={isPast ? '#718096' : '#e2e8f0'}
>
{program.sub_title || program.description}
</Text>
)}
<Text
size="sm"
style={{
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
overflow: 'hidden',
}}
>
{format(programStart, timeFormat)} -{' '}
{format(programEnd, timeFormat)}
</Text>
{isExpanded && (program.sub_title || seasonEpisodeLabel) && (
<Group gap="xs" wrap="nowrap" align="center">
{program.sub_title && (
<Text
size="xs"
size="sm"
fs="italic"
style={{
whiteSpace: 'nowrap',
@ -1008,7 +1049,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
overflow: 'hidden',
minWidth: 0,
}}
c={isPast ? '#718096' : '#a0aec0'}
c={isPast ? '#718096' : '#e2e8f0'}
>
{program.sub_title}
</Text>
@ -1025,28 +1066,16 @@ export default function TVChannelGuide({ startDate, endDate }) {
)}
</Group>
)}
<Text
size="sm"
style={{
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
overflow: 'hidden',
}}
>
{format(programStart, timeFormat)} -{' '}
{format(programEnd, timeFormat)}
</Text>
{(isExpanded || (!program.sub_title && !seasonEpisodeLabel)) && program.description && (
{isExpanded && program.description && (
<Text
size="xs"
style={{
whiteSpace: isExpanded ? 'normal' : 'nowrap',
textOverflow: isExpanded ? 'clip' : 'ellipsis',
overflow: isExpanded ? 'auto' : 'hidden',
whiteSpace: 'normal',
overflow: 'auto',
}}
mt={4}
c={isPast ? '#718096' : '#cbd5e0'}
mah={isExpanded ? '80px' : undefined}
mah={'100px'}
>
{program.description}
</Text>

View file

@ -15,7 +15,7 @@ import {
import API from '../api.js';
export const PROGRAM_HEIGHT = 90;
export const EXPANDED_PROGRAM_HEIGHT = 180;
export const EXPANDED_PROGRAM_HEIGHT = 200;
/** Layout constants */
export const CHANNEL_WIDTH = 120; // Width of the channel/logo column
export const HOUR_WIDTH = 450; // Increased from 300 to 450 to make each program wider
@ -214,7 +214,12 @@ export const mapChannelsById = (guideChannels) => {
return map;
};
const _terminalStatuses = new Set(['stopped', 'completed', 'interrupted', 'failed']);
const _terminalStatuses = new Set([
'stopped',
'completed',
'interrupted',
'failed',
]);
export const mapRecordingsByProgramId = (recordings) => {
const map = new Map();