Refactor GroupCatalog and enhance media preview functionality in ShowDetail and AddEpisodesDialog
ci / build-backend (push) Successful in 1m17s
ci / build-frontend (push) Successful in 39s
ci / tests (push) Successful in 3m41s
ci / sonar (push) Successful in 3m33s

Updated the GroupCatalog class to streamline the determination of the dominant show kind by introducing a new method, DominantKindOf, which improves code clarity. Enhanced the AddEpisodesDialog and ShowDetail components to integrate a media preview feature, allowing users to preview media assets directly within the dialog. This includes the addition of a preview state and corresponding UI elements for a better user experience. Updated localization strings to support the new preview functionality.
This commit is contained in:
Leonid Pershin
2026-07-30 23:30:34 +03:00
parent bee655ec1c
commit 01ba48e163
3 changed files with 90 additions and 22 deletions
@@ -113,14 +113,6 @@ public sealed class GroupCatalog(
if (units == 0) if (units == 0)
return null; return null;
// У коллекции своего типа нет: франшиза — это почти всегда полнометражки.
var seriesUnits = resolved.Where(r => r.ShowKind == ShowKind.Series).Sum(r => r.UnitCount);
// Ролики распознаются первыми: группа рекламы даёт сотни коротких единиц, то есть самый
// большой UnitCount из всех, и записанная «полным метром» она побеждала бы по баллам
// в любой полосе без предпочтения по типу — в эфир уходила бы полоса рекламы.
var interstitialUnits = resolved
.Where(r => r.ShowKind == ShowKind.Interstitial)
.Sum(r => r.UnitCount);
var duration = resolved.Aggregate(TimeSpan.Zero, (sum, r) => sum + r.TotalDuration); var duration = resolved.Aggregate(TimeSpan.Zero, (sum, r) => sum + r.TotalDuration);
return new GroupCandidate( return new GroupCandidate(
@@ -129,11 +121,30 @@ public sealed class GroupCatalog(
composition.Count, composition.Count,
units, units,
resolved.Max(r => r.Audience), resolved.Max(r => r.Audience),
interstitialUnits * 2 >= units ? ShowKind.Interstitial DominantKindOf(resolved, units),
: seriesUnits * 2 >= units ? ShowKind.Series
: ShowKind.Single,
duration.TotalMinutes / units, duration.TotalMinutes / units,
primaryGenreId primaryGenreId
); );
} }
/// <summary>
/// Чем группа является по составу — по единицам, а не по позициям: полоса собирается под длину
/// того, что в ней играет.
///
/// Ролики проверяются первыми: группа рекламы даёт сотни коротких единиц, то есть самый большой
/// <c>UnitCount</c> на канале, и записанная «полным метром» она побеждала бы по баллам в любой
/// полосе без предпочтения по типу — в эфир уходила бы полоса рекламы. У коллекции своего типа
/// нет: франшиза — это почти всегда полнометражки.
/// </summary>
private static ShowKind DominantKindOf(IReadOnlyList<GroupElementInfo> resolved, int units)
{
var interstitialUnits = resolved
.Where(r => r.ShowKind == ShowKind.Interstitial)
.Sum(r => r.UnitCount);
if (interstitialUnits * 2 >= units)
return ShowKind.Interstitial;
var seriesUnits = resolved.Where(r => r.ShowKind == ShowKind.Series).Sum(r => r.UnitCount);
return seriesUnits * 2 >= units ? ShowKind.Series : ShowKind.Single;
}
} }
@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Play, X } from 'lucide-react'
import { qk } from '@/shared/api/query-keys' import { qk } from '@/shared/api/query-keys'
import type { MediaAssetDto, ShowDto } from '@/shared/api/types' import type { MediaAssetDto, ShowDto } from '@/shared/api/types'
import { useApiError } from '@/shared/lib/use-api-error' import { useApiError } from '@/shared/lib/use-api-error'
@@ -15,9 +16,10 @@ import {
DialogTitle, DialogTitle,
} from '@/shared/ui/dialog' } from '@/shared/ui/dialog'
import { Input } from '@/shared/ui/input' import { Input } from '@/shared/ui/input'
import { HlsVideo } from '@/shared/ui/hls-video'
import { Pager } from '@/shared/ui/pager' import { Pager } from '@/shared/ui/pager'
import { toast } from '@/shared/ui/toast-store' import { toast } from '@/shared/ui/toast-store'
import { listAllMedia } from '@/features/admin/media/api' import { listAllMedia, mediaPreviewUrl } from '@/features/admin/media/api'
import { import {
type ParsedEpisode, type ParsedEpisode,
compareParsed, compareParsed,
@@ -56,6 +58,9 @@ export function AddEpisodesDialog({
const [deselected, setDeselected] = useState<Set<string>>(new Set()) const [deselected, setDeselected] = useState<Set<string>>(new Set())
const [adding, setAdding] = useState<{ current: number; total: number } | null>(null) const [adding, setAdding] = useState<{ current: number; total: number } | null>(null)
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
// Предпросмотр встроен в окно, а не открывается поверх: выбирают по именам файлов, и смотреть
// приходится подряд несколько — со второй модалкой это были бы два клика на каждый.
const [preview, setPreview] = useState<MediaAssetDto | null>(null)
const { data: ready, isLoading } = useQuery({ const { data: ready, isLoading } = useQuery({
queryKey: qk.media.ready, queryKey: qk.media.ready,
@@ -162,8 +167,9 @@ export function AddEpisodesDialog({
{items.map(({ asset, parsed }) => { {items.map(({ asset, parsed }) => {
const label = formatSeasonEpisode(parsed) const label = formatSeasonEpisode(parsed)
return ( return (
<li key={asset.id}> <li key={asset.id} className="flex items-center gap-2 pr-2 hover:bg-muted">
<label className="flex cursor-pointer items-center gap-3 px-4 py-2 hover:bg-muted"> {/* Кнопка вне label: внутри неё клик по «плею» заодно снимал бы галочку. */}
<label className="flex min-w-0 flex-1 cursor-pointer items-center gap-3 px-4 py-2">
<input <input
type="checkbox" type="checkbox"
checked={!deselected.has(asset.id)} checked={!deselected.has(asset.id)}
@@ -172,12 +178,31 @@ export function AddEpisodesDialog({
{label ? <Badge>{label}</Badge> : <Badge variant="muted"></Badge>} {label ? <Badge>{label}</Badge> : <Badge variant="muted"></Badge>}
<span className="truncate">{asset.originalFileName}</span> <span className="truncate">{asset.originalFileName}</span>
</label> </label>
<Button
size="sm"
variant="ghost"
title={t('admin.media.preview')}
onClick={() => setPreview(asset)}
>
<Play className="h-4 w-4" />
</Button>
</li> </li>
) )
})} })}
</ul> </ul>
)} )}
</div> </div>
{preview && (
<div className="crt-panel flex flex-col gap-2 rounded-md p-3">
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-sm">{preview.originalFileName}</span>
<Button size="sm" variant="ghost" onClick={() => setPreview(null)}>
<X className="h-4 w-4" />
</Button>
</div>
<HlsVideo src={mediaPreviewUrl(preview.id)} autoPlay />
</div>
)}
{ready?.truncated && ( {ready?.truncated && (
<p className="text-xs text-amber-500">{t('admin.shows.candidatesTruncated')}</p> <p className="text-xs text-amber-500">{t('admin.shows.candidatesTruncated')}</p>
)} )}
@@ -2,16 +2,19 @@ import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ChevronLeft, Plus } from 'lucide-react' import { ChevronLeft, Play, Plus } from 'lucide-react'
import { qk } from '@/shared/api/query-keys' import { qk } from '@/shared/api/query-keys'
import { AUDIENCE_UNSET, SHOW_AUDIENCES, type ShowAudience } from '@/shared/api/types' import { AUDIENCE_UNSET, SHOW_AUDIENCES, type ShowAudience } from '@/shared/api/types'
import { useApiError } from '@/shared/lib/use-api-error' import { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge' import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button' import { Button } from '@/shared/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
import { HlsVideo } from '@/shared/ui/hls-video'
import { Pager } from '@/shared/ui/pager' import { Pager } from '@/shared/ui/pager'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { formatSeasonEpisode, parseEpisodeName } from '@/features/admin/media/episode-parse' import { formatSeasonEpisode, parseEpisodeName } from '@/features/admin/media/episode-parse'
import { formatDuration } from '@/features/admin/media/format' import { formatDuration } from '@/features/admin/media/format'
import { mediaPreviewUrl } from '@/features/admin/media/api'
import { AddEpisodesDialog } from './AddEpisodesDialog' import { AddEpisodesDialog } from './AddEpisodesDialog'
import { ShowGenresField } from './ShowGenresField' import { ShowGenresField } from './ShowGenresField'
import { ShowMetadataCard } from './ShowMetadataCard' import { ShowMetadataCard } from './ShowMetadataCard'
@@ -25,6 +28,8 @@ export function ShowDetail({ showId }: Readonly<{ showId: string }>) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [addOpen, setAddOpen] = useState(false) const [addOpen, setAddOpen] = useState(false)
const [epPage, setEpPage] = useState(1) const [epPage, setEpPage] = useState(1)
/** Серия, которую смотрят: та же нарезка, что уйдёт в эфир. */
const [preview, setPreview] = useState<{ assetId: string; title: string } | null>(null)
const { data: show, isLoading } = useQuery({ const { data: show, isLoading } = useQuery({
queryKey: qk.shows.detail(showId), queryKey: qk.shows.detail(showId),
@@ -234,6 +239,23 @@ export function ShowDetail({ showId }: Readonly<{ showId: string }>) {
)} )}
</td> </td>
<td className="px-4 py-2"> <td className="px-4 py-2">
<div className="flex items-center gap-2">
{/* Смотреть можно только нарезанное: у необработанного ассета плейлиста нет. */}
{episode.assetStatus === 'Ready' && (
<Button
size="sm"
variant="outline"
title={t('admin.media.preview')}
onClick={() =>
setPreview({
assetId: episode.mediaAssetId,
title: episode.title ?? episode.assetName ?? t('admin.shows.episode'),
})
}
>
<Play className="h-4 w-4" />
</Button>
)}
<Button <Button
size="sm" size="sm"
variant="destructive" variant="destructive"
@@ -241,6 +263,7 @@ export function ShowDetail({ showId }: Readonly<{ showId: string }>) {
> >
{t('common.delete')} {t('common.delete')}
</Button> </Button>
</div>
</td> </td>
</tr> </tr>
) )
@@ -257,6 +280,15 @@ export function ShowDetail({ showId }: Readonly<{ showId: string }>) {
</div> </div>
<Pager page={epPageSafe} totalPages={epTotalPages} onChange={setEpPage} /> <Pager page={epPageSafe} totalPages={epTotalPages} onChange={setEpPage} />
<Dialog open={preview !== null} onOpenChange={(open) => !open && setPreview(null)}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle className="truncate">{preview?.title}</DialogTitle>
</DialogHeader>
{preview && <HlsVideo src={mediaPreviewUrl(preview.assetId)} autoPlay />}
</DialogContent>
</Dialog>
{addOpen && ( {addOpen && (
<AddEpisodesDialog <AddEpisodesDialog
show={show} show={show}