mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 00:55:50 +00:00
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:
parent
37fa6aac20
commit
9bffd6347d
10 changed files with 169 additions and 50 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue