Refactor GroupCatalog and enhance media preview functionality in ShowDetail and AddEpisodesDialog
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:
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user