Enhancement: Floating video player now displays the channel, stream, or VOD title in the player header bar. Title is passed through from all preview entry points: channel table, stream table, stream connection card, guide, DVR, recording cards, recording details modal, VOD modal, and series modal.

This commit is contained in:
SergeantPanda 2026-03-05 14:42:33 -06:00
parent 37fa6aac20
commit 9bffd6347d
10 changed files with 169 additions and 50 deletions

View file

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Floating video player now displays the channel, stream, or VOD title in the player header bar. Title is passed through from all preview entry points: channel table, stream table, stream connection card, guide, DVR, recording cards, recording details modal, VOD modal, and series modal.
- New Client Buffer proxy setting: new clients joining an active channel are now positioned a configurable number of seconds behind live rather than a fixed chunk count. The start position is determined by wall-clock chunk receive time (stored as a Redis sorted set alongside the buffer), so the buffer depth is consistent in seconds regardless of stream bitrate. Setting the value to `0` starts clients at live with no buffer. Defaults to 2 seconds. Existing chunk-count gating for the first client connecting to a channel is unchanged. The setting is exposed in Settings → Proxy as "New Client Buffer (seconds)".
- Channel table filter for channels that have stale streams: A new "Has Stale Streams" filter option in the channel table header menu highlights and filters channels containing at least one stale stream. Channels with stale streams are visually distinguished with an orange tint. The filter is mutually exclusive with "Only Empty Channels". - Thanks [@JCBird1012](https://github.com/JCBird1012)
- DVR enhancements — Thanks [@CodeBormen](https://github.com/CodeBormen)

View file

@ -718,13 +718,35 @@ export default function FloatingVideo() {
boxShadow: '0 2px 10px rgba(0,0,0,0.7)',
}}
>
{/* Simple header row with a close button */}
{/* Header row with optional title and close button */}
<Flex
justify="flex-end"
justify="space-between"
align="center"
style={{
padding: 3,
padding: '3px 3px 3px 8px',
minHeight: '38px',
}}
>
{metadata?.name ? (
<Text
size="xs"
fw={600}
style={{
color: 'rgba(255,255,255,0.9)',
flex: 1,
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
paddingRight: 4,
textShadow: '0px 1px 3px rgba(0,0,0,0.8)',
userSelect: 'none',
}}
>
{metadata.name}
</Text>
) : (
<Box style={{ flex: 1 }} />
)}
<CloseButton
onClick={handleClose}
onTouchEnd={handleClose}
@ -735,6 +757,7 @@ export default function FloatingVideo() {
minWidth: '32px',
cursor: 'pointer',
touchAction: 'manipulation',
flexShrink: 0,
}}
/>
</Flex>

View file

@ -99,7 +99,11 @@ const RecordingCard = ({
const status = customProps.status;
const isTimeActive = now.isAfter(start) && now.isBefore(end);
const isInterrupted = status === 'interrupted';
const isInProgress = isTimeActive && !isInterrupted && status !== 'completed' && status !== 'stopped';
const isInProgress =
isTimeActive &&
!isInterrupted &&
status !== 'completed' &&
status !== 'stopped';
const isUpcoming = now.isBefore(start);
const isSeriesGroup = Boolean(
recording._group_count && recording._group_count > 1
@ -114,7 +118,9 @@ const RecordingCard = ({
const handleWatchLive = () => {
if (!channel) return;
showVideo(getShowVideoUrl(channel, env_mode), 'live');
showVideo(getShowVideoUrl(channel, env_mode), 'live', {
name: channel.name,
});
};
const handleWatchRecording = () => {
@ -197,7 +203,9 @@ const RecordingCard = ({
} finally {
setBusy(false);
setStopConfirmOpen(false);
try { await fetchRecordings(); } catch {}
try {
await fetchRecordings();
} catch {}
}
};
@ -371,9 +379,15 @@ const RecordingCard = ({
</Menu.Target>
<Menu.Dropdown onClick={(e) => e.stopPropagation()}>
<Menu.Label>Extend recording by</Menu.Label>
<Menu.Item onClick={(e) => handleExtend(15, e)}>+15 minutes</Menu.Item>
<Menu.Item onClick={(e) => handleExtend(30, e)}>+30 minutes</Menu.Item>
<Menu.Item onClick={(e) => handleExtend(60, e)}>+1 hour</Menu.Item>
<Menu.Item onClick={(e) => handleExtend(15, e)}>
+15 minutes
</Menu.Item>
<Menu.Item onClick={(e) => handleExtend(30, e)}>
+30 minutes
</Menu.Item>
<Menu.Item onClick={(e) => handleExtend(60, e)}>
+1 hour
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Box>
@ -391,7 +405,15 @@ const RecordingCard = ({
</ActionIcon>
</Tooltip>
)}
<Tooltip label={isInProgress ? 'Cancel & delete' : isUpcoming ? 'Cancel' : 'Delete'}>
<Tooltip
label={
isInProgress
? 'Cancel & delete'
: isUpcoming
? 'Cancel'
: 'Delete'
}
>
<ActionIcon
variant="transparent"
color="red.9"
@ -467,12 +489,19 @@ const RecordingCard = ({
</Text>
)}
<Group justify="flex-end" gap="xs" pt={4} style={{ marginTop: 'auto' }}>
<Group
justify="flex-end"
gap="xs"
pt={4}
style={{ marginTop: 'auto' }}
>
{isInProgress && <WatchLive />}
{!isUpcoming && <WatchRecording />}
{!isUpcoming &&
(customProps?.status === 'completed' || customProps?.status === 'stopped' || customProps?.status === 'interrupted') &&
(customProps?.status === 'completed' ||
customProps?.status === 'stopped' ||
customProps?.status === 'interrupted') &&
(!customProps?.comskip ||
customProps?.comskip?.status !== 'completed') && (
<Button
@ -517,7 +546,11 @@ const RecordingCard = ({
will be saved and available for playback.
</Text>
<Group justify="flex-end">
<Button variant="default" onClick={() => setStopConfirmOpen(false)} disabled={busy}>
<Button
variant="default"
onClick={() => setStopConfirmOpen(false)}
disabled={busy}
>
Go Back
</Button>
<Button color="yellow" loading={busy} onClick={confirmStop}>
@ -530,7 +563,9 @@ const RecordingCard = ({
<Modal
opened={deleteConfirmOpen}
onClose={() => setDeleteConfirmOpen(false)}
title={isInProgress || isUpcoming ? 'Cancel Recording' : 'Delete Recording'}
title={
isInProgress || isUpcoming ? 'Cancel Recording' : 'Delete Recording'
}
centered
size="md"
zIndex={9999}
@ -544,11 +579,19 @@ const RecordingCard = ({
: 'This recording and all associated files will be permanently deleted.'}
</Text>
<Group justify="flex-end">
<Button variant="default" onClick={() => setDeleteConfirmOpen(false)} disabled={busy}>
<Button
variant="default"
onClick={() => setDeleteConfirmOpen(false)}
disabled={busy}
>
Go Back
</Button>
<Button color="red" loading={busy} onClick={confirmDelete}>
{isInProgress ? 'Cancel & Delete' : isUpcoming ? 'Cancel' : 'Delete'}
{isInProgress
? 'Cancel & Delete'
: isUpcoming
? 'Cancel'
: 'Delete'}
</Button>
</Group>
</Stack>
@ -556,12 +599,13 @@ const RecordingCard = ({
</>
);
if (!isSeriesGroup) return (
<>
{ConfirmModals}
{MainCard}
</>
);
if (!isSeriesGroup)
return (
<>
{ConfirmModals}
{MainCard}
</>
);
// Stacked look for series groups: render two shadow layers behind the main card
return (

View file

@ -431,7 +431,7 @@ const StreamConnectionCard = ({
url = `${window.location.protocol}//${window.location.hostname}:5656${uri}`;
}
showVideo(url);
showVideo(url, 'live', { name: actualChannel.name });
};
if (location.pathname !== '/stats') {

View file

@ -82,7 +82,12 @@ const RecordingDetailsModal = ({
// Derive poster URL from live store data instead of the stale prop snapshot.
const livePosterUrl = React.useMemo(
() => getPosterUrl(customProps.poster_logo_id, customProps, getChannelLogoUrl(channel)),
() =>
getPosterUrl(
customProps.poster_logo_id,
customProps,
getChannelLogoUrl(channel)
),
[customProps.poster_logo_id, customProps, channel]
);
@ -91,7 +96,8 @@ const RecordingDetailsModal = ({
const [savedTitle, setSavedTitle] = React.useState(null);
const [savedDescription, setSavedDescription] = React.useState(null);
const recordingName = savedTitle ?? (program.title || 'Custom Recording');
const description = savedDescription ?? (program.description || customProps.description || '');
const description =
savedDescription ?? (program.description || customProps.description || '');
const [editTitle, setEditTitle] = React.useState('');
const [editDescription, setEditDescription] = React.useState('');
@ -186,7 +192,9 @@ const RecordingDetailsModal = ({
channelsById[rec.channel] ||
(rec.channel === recording?.channel ? channel : null);
if (!ch) return;
useVideoStore.getState().showVideo(getShowVideoUrl(ch, env_mode), 'live');
useVideoStore
.getState()
.showVideo(getShowVideoUrl(ch, env_mode), 'live', { name: ch.name });
}
};
@ -281,7 +289,9 @@ const RecordingDetailsModal = ({
const se = getSeasonLabel(season, episode, onscreen);
const posterLogoId = cp.poster_logo_id;
const purl = getPosterUrl(posterLogoId, cp, livePosterUrl);
const epChannel = channelsById[rec.channel] || (rec.channel === recording?.channel ? channel : null);
const epChannel =
channelsById[rec.channel] ||
(rec.channel === recording?.channel ? channel : null);
const onRemove = async (e) => {
e?.stopPropagation?.();
@ -533,12 +543,25 @@ const RecordingDetailsModal = ({
size="sm"
style={{ flex: 1 }}
autoFocus
onKeyDown={(e) => { if (e.key === 'Enter') saveMetadata(); if (e.key === 'Escape') cancelEditing(); }}
onKeyDown={(e) => {
if (e.key === 'Enter') saveMetadata();
if (e.key === 'Escape') cancelEditing();
}}
/>
<ActionIcon size="sm" variant="subtle" color="green" onClick={saveMetadata}>
<ActionIcon
size="sm"
variant="subtle"
color="green"
onClick={saveMetadata}
>
<Check size={14} />
</ActionIcon>
<ActionIcon size="sm" variant="subtle" color="gray" onClick={cancelEditing}>
<ActionIcon
size="sm"
variant="subtle"
color="gray"
onClick={cancelEditing}
>
<X size={14} />
</ActionIcon>
</Group>
@ -552,7 +575,12 @@ const RecordingDetailsModal = ({
: `${recordingName}${program.sub_title ? ` - ${program.sub_title}` : ''}`}
</span>
{!isSeriesGroup && (
<ActionIcon size="sm" variant="subtle" color="dimmed" onClick={startEditing}>
<ActionIcon
size="sm"
variant="subtle"
color="dimmed"
onClick={startEditing}
>
<Pencil size={14} />
</ActionIcon>
)}

View file

@ -140,12 +140,12 @@ const ChannelStreams = ({ channel, isExpanded }) => {
const authUser = useAuthStore((s) => s.user);
const showVideo = useVideoStore((s) => s.showVideo);
const env_mode = useSettingsStore((s) => s.environment.env_mode);
function handleWatchStream(streamHash) {
function handleWatchStream(streamHash, streamName) {
let vidUrl = `/proxy/ts/stream/${streamHash}`;
if (env_mode === 'dev') {
vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`;
}
showVideo(vidUrl);
showVideo(vidUrl, 'live', streamName ? { name: streamName } : null);
}
const [data, setData] = useState(channelStreams || []);
@ -390,7 +390,10 @@ const ChannelStreams = ({ channel, isExpanded }) => {
color="blue"
variant="light"
onClick={() =>
handleWatchStream(stream.stream_hash || stream.id)
handleWatchStream(
stream.stream_hash || stream.id,
stream.name
)
}
style={{ marginLeft: 2 }}
>

View file

@ -693,7 +693,7 @@ const ChannelsTable = ({ onReady }) => {
);
const url = getChannelURL(channel);
console.log(`Stream URL: ${url}`);
showVideo(url);
showVideo(url, 'live', { name: channel.name });
};
const onRowSelectionChange = (newSelection) => {

View file

@ -112,7 +112,7 @@ const StreamRowActions = ({
'Hash:',
row.original.stream_hash
);
handleWatchStream(row.original.stream_hash);
handleWatchStream(row.original.stream_hash, row.original.name);
}, [row.original, handleWatchStream]); // Add proper dependencies to ensure correct stream
const iconSize =
@ -1012,12 +1012,12 @@ const StreamsTable = ({ onReady }) => {
});
};
function handleWatchStream(streamHash) {
function handleWatchStream(streamHash, streamName) {
let vidUrl = `/proxy/ts/stream/${streamHash}`;
if (env_mode == 'dev') {
vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`;
}
showVideo(vidUrl);
showVideo(vidUrl, 'live', streamName ? { name: streamName } : null);
}
const onSortingChange = (column) => {

View file

@ -26,7 +26,11 @@ const RecordingDetailsModal = lazy(
);
import RecurringRuleModal from '../components/forms/RecurringRuleModal.jsx';
import RecordingCard from '../components/cards/RecordingCard.jsx';
import { categorizeRecordings, filterRecordings, buildChannelOptions } from '../utils/pages/DVRUtils.js';
import {
categorizeRecordings,
filterRecordings,
buildChannelOptions,
} from '../utils/pages/DVRUtils.js';
import {
getChannelLogoUrl,
getPosterUrl,
@ -101,7 +105,12 @@ const DVRPage = () => {
setDetailsOpen(false);
setDetailsRecording(null);
setEditRecording(null);
setRuleModal({ open: true, ruleId, recording, isDelete: isDelete || false });
setRuleModal({
open: true,
ruleId,
recording,
isDelete: isDelete || false,
});
};
const closeRuleModal = () => setRuleModal({ open: false, ruleId: null });
@ -152,7 +161,8 @@ const DVRPage = () => {
}, [channelsById, inProgress, upcoming, completed]);
// Filtered buckets
const hasActiveFilters = searchQuery !== '' || selectedChannelId !== null || selectedStatus !== null;
const hasActiveFilters =
searchQuery !== '' || selectedChannelId !== null || selectedStatus !== null;
const filteredInProgress = useMemo(() => {
if (selectedStatus && selectedStatus !== 'recording') return [];
@ -165,13 +175,21 @@ const DVRPage = () => {
}, [upcoming, searchQuery, selectedChannelId, selectedStatus]);
const filteredCompleted = useMemo(() => {
if (selectedStatus && !['completed', 'interrupted'].includes(selectedStatus)) return [];
if (
selectedStatus &&
!['completed', 'interrupted'].includes(selectedStatus)
)
return [];
let filtered = filterRecordings(completed, searchQuery, selectedChannelId);
if (selectedStatus === 'interrupted') {
filtered = filtered.filter((rec) => rec.custom_properties?.status === 'interrupted');
filtered = filtered.filter(
(rec) => rec.custom_properties?.status === 'interrupted'
);
} else if (selectedStatus === 'completed') {
// "Completed" includes both completed and stopped recordings
filtered = filtered.filter((rec) => rec.custom_properties?.status !== 'interrupted');
filtered = filtered.filter(
(rec) => rec.custom_properties?.status !== 'interrupted'
);
}
return filtered;
}, [completed, searchQuery, selectedChannelId, selectedStatus]);
@ -196,7 +214,7 @@ const DVRPage = () => {
channel,
useSettingsStore.getState().environment.env_mode
);
useVideoStore.getState().showVideo(url, 'live');
useVideoStore.getState().showVideo(url, 'live', { name: channel.name });
}
};

View file

@ -724,7 +724,9 @@ export default function TVChannelGuide({ startDate, endDate }) {
return;
}
showVideo(getShowVideoUrl(matched, env_mode));
showVideo(getShowVideoUrl(matched, env_mode), 'live', {
name: matched.name,
});
},
[env_mode, findChannelByTvgId, showVideo]
);
@ -733,7 +735,9 @@ export default function TVChannelGuide({ startDate, endDate }) {
(channel, event) => {
event.stopPropagation();
showVideo(getShowVideoUrl(channel, env_mode));
showVideo(getShowVideoUrl(channel, env_mode), 'live', {
name: channel.name,
});
},
[env_mode, showVideo]
);
@ -1342,9 +1346,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
}}
pos="relative"
>
<LoadingOverlay
visible={isProgramsLoading || isChannelsLoading}
/>
<LoadingOverlay visible={isProgramsLoading || isChannelsLoading} />
{nowPosition >= 0 && (
<Box
ref={nowLineRef}