Refactor media preview functionality and update localization strings
Moved the mediaPreviewUrl function to the media API for better organization and clarity. Updated the MediaPanel component to include a preview feature for media assets, allowing users to view processed clips. Enhanced the UI to include a dialog for media previews and added corresponding localization strings in both English and Russian for the new preview functionality.
This commit is contained in:
@@ -16,7 +16,8 @@ import { HlsVideo } from '@/shared/ui/hls-video'
|
|||||||
import { Input } from '@/shared/ui/input'
|
import { Input } from '@/shared/ui/input'
|
||||||
import { BlockBuilder } from './BlockBuilder'
|
import { BlockBuilder } from './BlockBuilder'
|
||||||
import { ClipGroupPanel } from './ClipGroupPanel'
|
import { ClipGroupPanel } from './ClipGroupPanel'
|
||||||
import { listInterstitialBlocks, listInterstitials, mediaPreviewUrl } from './api'
|
import { mediaPreviewUrl } from '@/features/admin/media/api'
|
||||||
|
import { listInterstitialBlocks, listInterstitials } from './api'
|
||||||
import { setDragItem } from './dnd'
|
import { setDragItem } from './dnd'
|
||||||
import { formatClock } from './format'
|
import { formatClock } from './format'
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,3 @@ export function importInterstitials(mediaAssetIds: string[]) {
|
|||||||
body: { mediaAssetIds },
|
body: { mediaAssetIds },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Плейлист обработанного ассета под admin-роутом (JWT) — грузится через hls.js. */
|
|
||||||
export function mediaPreviewUrl(assetId: string) {
|
|
||||||
return `/api/admin/media/${assetId}/preview/index.m3u8`
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { FolderInput, Upload } from 'lucide-react'
|
import { FolderInput, Play, Upload } from 'lucide-react'
|
||||||
import { qk } from '@/shared/api/query-keys'
|
import { qk } from '@/shared/api/query-keys'
|
||||||
import type { MediaAssetDto, MediaAssetStatus } from '@/shared/api/types'
|
import type { MediaAssetDto, MediaAssetStatus } from '@/shared/api/types'
|
||||||
import { useApiError } from '@/shared/lib/use-api-error'
|
import { useApiError } from '@/shared/lib/use-api-error'
|
||||||
import { Badge, type BadgeProps } from '@/shared/ui/badge'
|
import { Badge, type BadgeProps } 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 { toast } from '@/shared/ui/toast-store'
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
import { useTableSort } from '@/shared/lib/table-sort'
|
import { useTableSort } from '@/shared/lib/table-sort'
|
||||||
import { SortHeader } from '@/shared/ui/sortable'
|
import { SortHeader } from '@/shared/ui/sortable'
|
||||||
import { deleteMedia, getMediaStats, listMedia } from './api'
|
import { deleteMedia, getMediaStats, listMedia, mediaPreviewUrl } from './api'
|
||||||
import { formatDuration, splitEta } from './format'
|
import { formatDuration, splitEta } from './format'
|
||||||
import { MediaImportDialog } from './MediaImportDialog'
|
import { MediaImportDialog } from './MediaImportDialog'
|
||||||
import { useUploadStore } from './upload-store'
|
import { useUploadStore } from './upload-store'
|
||||||
@@ -45,6 +47,7 @@ export function MediaPanel() {
|
|||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const { sort, toggle } = useTableSort('created', true)
|
const { sort, toggle } = useTableSort('created', true)
|
||||||
const [importOpen, setImportOpen] = useState(false)
|
const [importOpen, setImportOpen] = useState(false)
|
||||||
|
const [preview, setPreview] = useState<MediaAssetDto | null>(null)
|
||||||
const enqueue = useUploadStore((s) => s.enqueue)
|
const enqueue = useUploadStore((s) => s.enqueue)
|
||||||
|
|
||||||
const sortColumn = (key: string) => {
|
const sortColumn = (key: string) => {
|
||||||
@@ -230,6 +233,7 @@ export function MediaPanel() {
|
|||||||
<MediaRow
|
<MediaRow
|
||||||
key={asset.id}
|
key={asset.id}
|
||||||
asset={asset}
|
asset={asset}
|
||||||
|
onPreview={() => setPreview(asset)}
|
||||||
onDelete={() => deleteMutation.mutate(asset.id)}
|
onDelete={() => deleteMutation.mutate(asset.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -249,6 +253,17 @@ export function MediaPanel() {
|
|||||||
totalPages={data ? Math.max(1, Math.ceil(data.total / PAGE_SIZE)) : 1}
|
totalPages={data ? Math.max(1, Math.ceil(data.total / PAGE_SIZE)) : 1}
|
||||||
onChange={setPage}
|
onChange={setPage}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Dialog open={preview !== null} onOpenChange={(open) => !open && setPreview(null)}>
|
||||||
|
<DialogContent className="max-w-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="truncate">{preview?.originalFileName}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{/* Смотрят нарезку, а не исходник: в эфир идёт именно она, и битый звук или чёрный кадр
|
||||||
|
видно только здесь. Кнопка в списке — это «плей», поэтому запускаем сразу. */}
|
||||||
|
{preview && <HlsVideo src={mediaPreviewUrl(preview.id)} autoPlay />}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -268,7 +283,11 @@ function EtaValue({ seconds }: Readonly<{ seconds: number }>) {
|
|||||||
return <>~{[hours, minutes].filter(Boolean).join(' ')}</>
|
return <>~{[hours, minutes].filter(Boolean).join(' ')}</>
|
||||||
}
|
}
|
||||||
|
|
||||||
function MediaRow({ asset, onDelete }: Readonly<{ asset: MediaAssetDto; onDelete: () => void }>) {
|
function MediaRow({
|
||||||
|
asset,
|
||||||
|
onPreview,
|
||||||
|
onDelete,
|
||||||
|
}: Readonly<{ asset: MediaAssetDto; onPreview: () => void; onDelete: () => void }>) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
return (
|
return (
|
||||||
<tr className="border-b border-border last:border-0">
|
<tr className="border-b border-border last:border-0">
|
||||||
@@ -286,9 +305,22 @@ function MediaRow({ asset, onDelete }: Readonly<{ asset: MediaAssetDto; onDelete
|
|||||||
{asset.status === 'Ready' ? formatDuration(asset.processingSeconds) : '—'}
|
{asset.status === 'Ready' ? formatDuration(asset.processingSeconds) : '—'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Смотреть можно только нарезанное: у необработанного ассета плейлиста ещё нет. */}
|
||||||
|
{asset.status === 'Ready' && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
title={t('admin.media.preview')}
|
||||||
|
onClick={onPreview}
|
||||||
|
>
|
||||||
|
<Play className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button size="sm" variant="destructive" onClick={onDelete}>
|
<Button size="sm" variant="destructive" onClick={onDelete}>
|
||||||
{t('common.delete')}
|
{t('common.delete')}
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -161,3 +161,11 @@ export function importMovies(items: MovieImportItem[], provider: string) {
|
|||||||
body: { items, provider },
|
body: { items, provider },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Плейлист обработанного ассета под admin-роутом (JWT) — грузится через hls.js, а не ставится
|
||||||
|
* в <video> напрямую: сегменты лежат за той же авторизацией, что и плейлист.
|
||||||
|
*/
|
||||||
|
export function mediaPreviewUrl(assetId: string) {
|
||||||
|
return `/api/admin/media/${assetId}/preview/index.m3u8`
|
||||||
|
}
|
||||||
|
|||||||
@@ -297,6 +297,7 @@ export const en = {
|
|||||||
media: {
|
media: {
|
||||||
title: 'Media',
|
title: 'Media',
|
||||||
upload: 'Upload',
|
upload: 'Upload',
|
||||||
|
preview: 'Preview',
|
||||||
manualButton: 'From manual folder',
|
manualButton: 'From manual folder',
|
||||||
importButton: 'Import',
|
importButton: 'Import',
|
||||||
importTitle: 'Media import',
|
importTitle: 'Media import',
|
||||||
|
|||||||
@@ -298,6 +298,7 @@ export const ru = {
|
|||||||
media: {
|
media: {
|
||||||
title: 'Медиа',
|
title: 'Медиа',
|
||||||
upload: 'Загрузить',
|
upload: 'Загрузить',
|
||||||
|
preview: 'Просмотр',
|
||||||
manualButton: 'Из папки manual',
|
manualButton: 'Из папки manual',
|
||||||
importButton: 'Импорт',
|
importButton: 'Импорт',
|
||||||
importTitle: 'Импорт медиа',
|
importTitle: 'Импорт медиа',
|
||||||
|
|||||||
Reference in New Issue
Block a user