Enhance channel settings to include icon image support for IPTV
Updated the Channel and related DTOs to incorporate an optional IconImageId property, allowing for the specification of a channel icon for external IPTV players. Modified the UpdateChannelSettings command and its handler to accommodate this new property. Adjusted frontend components to manage the icon image, including selection and display functionality. Updated localization files to support new UI elements related to the IPTV icon. This enhancement improves the user experience by providing a visual representation of channels in IPTV applications.
This commit is contained in:
@@ -156,7 +156,13 @@ public static class ChannelEndpoints
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new UpdateChannelSettingsCommand(id, body.Name, body.IsEnabled, body.FillerAssetId),
|
||||
new UpdateChannelSettingsCommand(
|
||||
id,
|
||||
body.Name,
|
||||
body.IsEnabled,
|
||||
body.FillerAssetId,
|
||||
body.IconImageId
|
||||
),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
@@ -187,7 +193,12 @@ public sealed record UpdateChannelTimeBody(
|
||||
TimeOnly DayStartTime
|
||||
);
|
||||
|
||||
public sealed record UpdateChannelSettingsBody(string Name, bool IsEnabled, Guid? FillerAssetId);
|
||||
public sealed record UpdateChannelSettingsBody(
|
||||
string Name,
|
||||
bool IsEnabled,
|
||||
Guid? FillerAssetId,
|
||||
Guid? IconImageId
|
||||
);
|
||||
|
||||
/// <summary>Оверлеи и аналоговый фильтр — как канал выглядит у зрителя (см. 6.8).</summary>
|
||||
public sealed record UpdateViewerSettingsBody(
|
||||
|
||||
@@ -21,6 +21,9 @@ public static class IptvEndpoints
|
||||
/// <summary>Сколько суток программы класть в XMLTV: горизонт ленты и так неделя.</summary>
|
||||
private static readonly TimeSpan GuideWindow = TimeSpan.FromDays(7);
|
||||
|
||||
/// <summary>Заглушка иконки канала, когда своя не задана: фавикон сайта из wwwroot.</summary>
|
||||
private const string FaviconPath = "/favicon.svg";
|
||||
|
||||
public static IEndpointRouteBuilder MapIptvEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var iptv = app.MapGroup("/api/iptv").WithTags("IPTV");
|
||||
@@ -109,9 +112,8 @@ public static class IptvEndpoints
|
||||
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
var logo = channel.LogoImageId is { } imageId
|
||||
? $" tvg-logo=\"{origin}/api/images/{imageId}\""
|
||||
: string.Empty;
|
||||
// tvg-logo ставим всегда: своя иконка либо фавикон-заглушка — иначе в плеере пустой квадрат.
|
||||
var logo = $" tvg-logo=\"{ChannelIcon(origin, channel.IconImageId)}\"";
|
||||
var number = channel.Number is { } value
|
||||
? $" tvg-chno=\"{value.ToString(CultureInfo.InvariantCulture)}\""
|
||||
: string.Empty;
|
||||
@@ -189,7 +191,10 @@ public static class IptvEndpoints
|
||||
"display-name",
|
||||
number.ToString(CultureInfo.InvariantCulture)
|
||||
);
|
||||
WriteIcon(writer, channel.LogoImageId, origin);
|
||||
// Иконку канала пишем всегда: своя либо фавикон-заглушка (в отличие от иконок передач ниже).
|
||||
writer.WriteStartElement("icon");
|
||||
writer.WriteAttributeString("src", ChannelIcon(origin, channel.IconImageId));
|
||||
writer.WriteEndElement();
|
||||
writer.WriteEndElement();
|
||||
}
|
||||
|
||||
@@ -225,6 +230,10 @@ public static class IptvEndpoints
|
||||
writer.WriteEndElement();
|
||||
}
|
||||
|
||||
/// <summary>URL иконки канала: своя из реестра либо фавикон-заглушка сайта (абсолютный адрес).</summary>
|
||||
private static string ChannelIcon(string origin, Guid? iconImageId) =>
|
||||
iconImageId is { } id ? $"{origin}/api/images/{id}" : $"{origin}{FaviconPath}";
|
||||
|
||||
/// <summary>Время XMLTV: <c>YYYYMMDDHHMMSS +0000</c>. Отдаём в UTC — плеер сдвинет сам.</summary>
|
||||
private static string XmltvTime(DateTimeOffset moment) =>
|
||||
moment.ToUniversalTime().ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture)
|
||||
|
||||
@@ -14,6 +14,8 @@ public sealed record ChannelDto(
|
||||
TimeOnly DayStartTime,
|
||||
Guid? TemplateId,
|
||||
Guid? FillerAssetId,
|
||||
/// <summary>Иконка канала для внешних IPTV-плееров (M3U/XMLTV); null — отдаётся заглушка.</summary>
|
||||
Guid? IconImageId,
|
||||
/// <summary>Оверлеи и фильтр зрительской части — всё опционально (см. 6.8).</summary>
|
||||
ViewerSettingsDto Viewer
|
||||
);
|
||||
|
||||
@@ -32,6 +32,7 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
|
||||
channel.DayStartTime,
|
||||
channel.TemplateId,
|
||||
channel.FillerAssetId,
|
||||
channel.IconImageId,
|
||||
new ViewerSettingsDto(
|
||||
channel.LogoImageId,
|
||||
channel.LogoCorner,
|
||||
|
||||
+2
-1
@@ -7,5 +7,6 @@ public sealed record UpdateChannelSettingsCommand(
|
||||
Guid ChannelId,
|
||||
string Name,
|
||||
bool IsEnabled,
|
||||
Guid? FillerAssetId
|
||||
Guid? FillerAssetId,
|
||||
Guid? IconImageId
|
||||
) : ICommand<Result>;
|
||||
|
||||
+6
-1
@@ -30,7 +30,12 @@ public sealed class UpdateChannelSettingsCommandHandler(IAppDbContext dbContext)
|
||||
return Result.Failure(ChannelErrors.AssetNotFound);
|
||||
}
|
||||
|
||||
channel.UpdateSettings(command.Name, command.IsEnabled, command.FillerAssetId);
|
||||
channel.UpdateSettings(
|
||||
command.Name,
|
||||
command.IsEnabled,
|
||||
command.FillerAssetId,
|
||||
command.IconImageId
|
||||
);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ public sealed class GetIptvGuideQueryHandler(IAppDbContext dbContext)
|
||||
c.Slug,
|
||||
c.Name,
|
||||
c.Number,
|
||||
c.LogoImageId,
|
||||
c.IconImageId,
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
if (channels.Count == 0)
|
||||
@@ -129,7 +129,7 @@ public sealed class GetIptvGuideQueryHandler(IAppDbContext dbContext)
|
||||
channel.Slug,
|
||||
channel.Name,
|
||||
channel.Number,
|
||||
channel.LogoImageId,
|
||||
channel.IconImageId,
|
||||
programmes
|
||||
)
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ public sealed record IptvChannelDto(
|
||||
string Slug,
|
||||
string Name,
|
||||
int? Number,
|
||||
Guid? LogoImageId,
|
||||
Guid? IconImageId,
|
||||
IReadOnlyList<IptvProgrammeDto> Programmes
|
||||
);
|
||||
|
||||
|
||||
@@ -46,6 +46,14 @@ public class Channel
|
||||
/// <summary>Ассет-заглушка на случай пустого расписания (аварийная подстраховка).</summary>
|
||||
public Guid? FillerAssetId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Иконка канала для внешних IPTV-плееров (tvg-logo в M3U и <c><icon></c> в XMLTV): ссылка
|
||||
/// на реестр изображений или null. Отдельная от экранного логотипа-оверлея
|
||||
/// (<see cref="LogoImageId"/>): тот — полупрозрачный водяной знак в углу видео, а здесь нужна
|
||||
/// нормальная квадратная иконка канала. Null — плеер получит заглушку (фавикон сайта).
|
||||
/// </summary>
|
||||
public Guid? IconImageId { get; private set; }
|
||||
|
||||
// ── Зрительская часть (см. 6.8). Всё рисуется на клиенте поверх <video>, ffmpeg не трогает,
|
||||
// и всё по умолчанию выключено: канал без логотипа и без шума — законная конфигурация. ──
|
||||
|
||||
@@ -81,11 +89,12 @@ public class Channel
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
public void UpdateSettings(string name, bool isEnabled, Guid? fillerAssetId)
|
||||
public void UpdateSettings(string name, bool isEnabled, Guid? fillerAssetId, Guid? iconImageId)
|
||||
{
|
||||
Name = name;
|
||||
IsEnabled = isEnabled;
|
||||
FillerAssetId = fillerAssetId;
|
||||
IconImageId = iconImageId;
|
||||
}
|
||||
|
||||
/// <summary>Оверлеи и фильтр зрительской части. Всё опционально; силы зажимаются в 0..1.</summary>
|
||||
|
||||
Generated
+1605
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TeleWave.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddChannelIconImage : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "IconImageId",
|
||||
table: "Channels",
|
||||
type: "uuid",
|
||||
nullable: true
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(name: "IconImageId", table: "Channels");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -317,6 +317,9 @@ namespace TeleWave.Infrastructure.Migrations
|
||||
b.Property<Guid?>("FillerAssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("IconImageId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ public class ChannelDetailsTests
|
||||
var channel = Channel.Create("Первый", "one", T0);
|
||||
var fillerId = Guid.NewGuid();
|
||||
var logoId = Guid.NewGuid();
|
||||
channel.UpdateSettings("Первый", isEnabled: true, fillerId);
|
||||
channel.UpdateSettings("Первый", isEnabled: true, fillerId, null);
|
||||
channel.UpdateTimeSettings(3, 120, new TimeOnly(5, 0));
|
||||
channel.UpdateViewerSettings(logoId, LogoCorner.BottomLeft, 0.4, showClock: true, 0.25);
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ public class ChannelHandlersTests
|
||||
|
||||
await using var db = fixture.New();
|
||||
var result = await new UpdateChannelSettingsCommandHandler(db).Handle(
|
||||
new UpdateChannelSettingsCommand(channel.Id, "c2", true, null),
|
||||
new UpdateChannelSettingsCommand(channel.Id, "c2", true, null, null),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.True(result.IsSuccess);
|
||||
|
||||
@@ -168,7 +168,7 @@ public class DeleteGuardsTests
|
||||
var fixture = new TestDb();
|
||||
var asset = MediaAsset.Register("filler.mkv", ".mkv", MediaSource.Upload);
|
||||
var channel = Channel.Create("c", "c", T0);
|
||||
channel.UpdateSettings(channel.Name, isEnabled: true, asset.Id);
|
||||
channel.UpdateSettings(channel.Name, isEnabled: true, asset.Id, null);
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
|
||||
@@ -92,7 +92,7 @@ public class TelegramBotTests
|
||||
var userId = Guid.NewGuid();
|
||||
var code = TelegramLinkCode.Issue("abc123", userId, Now);
|
||||
var channel = Channel.Create("Мультреалити", "mult", Now);
|
||||
channel.UpdateSettings(channel.Name, isEnabled: true, null);
|
||||
channel.UpdateSettings(channel.Name, isEnabled: true, null, null);
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
@@ -145,7 +145,7 @@ public class TelegramBotTests
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var channel = Channel.Create("Мультреалити", "mult", Now);
|
||||
channel.UpdateSettings(channel.Name, isEnabled: true, null);
|
||||
channel.UpdateSettings(channel.Name, isEnabled: true, null, null);
|
||||
var subscriber = TelegramSubscriber.Create(100, Guid.NewGuid(), "viewer", Now);
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
@@ -194,7 +194,7 @@ public class TelegramBotTests
|
||||
// один блок: зритель спрашивает «что дальше», а не «в каком порядке лежат файлы».
|
||||
var fixture = new TestDb();
|
||||
var channel = Channel.Create("Мультреалити", "mult", Now);
|
||||
channel.UpdateSettings(channel.Name, isEnabled: true, null);
|
||||
channel.UpdateSettings(channel.Name, isEnabled: true, null, null);
|
||||
var first = Show.Create("Симпсоны", ShowKind.Series);
|
||||
var second = Show.Create("Футурама", ShowKind.Series);
|
||||
var subscriber = TelegramSubscriber.Create(100, Guid.NewGuid(), "viewer", Now);
|
||||
@@ -250,7 +250,7 @@ public class TelegramBotTests
|
||||
// Два меню в чате — это две панели с разными галочками, и одна из них врёт. Старое снимаем.
|
||||
var fixture = new TestDb();
|
||||
var channel = Channel.Create("Мультреалити", "mult", Now);
|
||||
channel.UpdateSettings(channel.Name, isEnabled: true, null);
|
||||
channel.UpdateSettings(channel.Name, isEnabled: true, null, null);
|
||||
var subscriber = TelegramSubscriber.Create(100, Guid.NewGuid(), "viewer", Now);
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
|
||||
@@ -125,7 +125,7 @@ public class IptvGuideTests
|
||||
var fixture = new TestDb();
|
||||
var visible = Enabled("Первый", "one");
|
||||
var hidden = Channel.Create("Выключенный", "off", T0);
|
||||
hidden.UpdateSettings("Выключенный", isEnabled: false, null);
|
||||
hidden.UpdateSettings("Выключенный", isEnabled: false, null, null);
|
||||
|
||||
var show = Show.Create("Ворон", ShowKind.Single);
|
||||
var asset = MediaAsset.Register("crow.mkv", ".mkv", MediaSource.Upload);
|
||||
@@ -187,7 +187,7 @@ public class IptvGuideTests
|
||||
private static Channel Enabled(string name, string slug)
|
||||
{
|
||||
var channel = Channel.Create(name, slug, T0);
|
||||
channel.UpdateSettings(name, isEnabled: true, null);
|
||||
channel.UpdateSettings(name, isEnabled: true, null, null);
|
||||
return channel;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,8 +31,8 @@ public class ListPublicChannelsTests
|
||||
var disabled = Channel.Create("Выключенный", "off", T0);
|
||||
|
||||
foreach (var channel in new[] { numbered, first, unnumbered })
|
||||
channel.UpdateSettings(channel.Name, isEnabled: true, null);
|
||||
disabled.UpdateSettings("Выключенный", isEnabled: false, null);
|
||||
channel.UpdateSettings(channel.Name, isEnabled: true, null, null);
|
||||
disabled.UpdateSettings("Выключенный", isEnabled: false, null, null);
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
@@ -57,7 +57,7 @@ public class ListPublicChannelsTests
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var channel = Channel.Create("Первый", "one", T0);
|
||||
channel.UpdateSettings("Первый", isEnabled: true, null);
|
||||
channel.UpdateSettings("Первый", isEnabled: true, null, null);
|
||||
var logoId = Guid.NewGuid();
|
||||
channel.UpdateViewerSettings(logoId, LogoCorner.TopRight, 0.5, showClock: true, 0.3);
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ public class ValidatorTests
|
||||
{
|
||||
var v = new UpdateChannelSettingsCommandValidator();
|
||||
|
||||
var good = new UpdateChannelSettingsCommand(Guid.NewGuid(), "Name", true, null);
|
||||
var good = new UpdateChannelSettingsCommand(Guid.NewGuid(), "Name", true, null, null);
|
||||
Assert.True(v.Validate(good).IsValid);
|
||||
|
||||
Assert.False(v.Validate(good with { Name = "" }).IsValid);
|
||||
|
||||
@@ -29,7 +29,7 @@ public class ChannelTests
|
||||
var channel = NewChannel();
|
||||
var filler = Guid.NewGuid();
|
||||
|
||||
channel.UpdateSettings("N2", false, filler);
|
||||
channel.UpdateSettings("N2", false, filler, null);
|
||||
|
||||
Assert.Equal("N2", channel.Name);
|
||||
Assert.False(channel.IsEnabled);
|
||||
|
||||
@@ -386,7 +386,7 @@ public sealed class GridScheduleGeneratorIntegrationTests(PostgresFixture fixtur
|
||||
var filler = ReadyAsset(db, $"filler-{suffix}.mkv", TimeSpan.FromMinutes(1));
|
||||
|
||||
var channel = Channel.Create($"Канал {suffix}", $"ch-{suffix}", Now.AddDays(-7));
|
||||
channel.UpdateSettings(channel.Name, isEnabled: true, filler.Id);
|
||||
channel.UpdateSettings(channel.Name, isEnabled: true, filler.Id, null);
|
||||
|
||||
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
|
||||
var layer = template.AddLayer("Базовый", 10);
|
||||
@@ -436,7 +436,7 @@ public sealed class GridScheduleGeneratorIntegrationTests(PostgresFixture fixtur
|
||||
var filler = ReadyAsset(db, $"filler-{suffix}.mkv", TimeSpan.FromMinutes(30));
|
||||
|
||||
var channel = Channel.Create($"Канал {suffix}", $"ch-{suffix}", Now.AddDays(-7));
|
||||
channel.UpdateSettings(channel.Name, isEnabled: true, filler.Id);
|
||||
channel.UpdateSettings(channel.Name, isEnabled: true, filler.Id, null);
|
||||
|
||||
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
|
||||
var layer = template.AddLayer("Базовый", 10);
|
||||
|
||||
@@ -1131,6 +1131,14 @@ seed = hash(channelId, date, slotId, occurrenceInDay)
|
||||
включённых каналов и ссылка на XMLTV с программой передач (`/api/iptv/epg.xml`, горизонт 7 суток,
|
||||
подряд идущие серии одного шоу склеены в одну передачу, врезки и заставки в гид не попадают).
|
||||
|
||||
Иконка канала для плеера (`tvg-logo` в M3U и `<icon>` канала в XMLTV) — это **отдельное поле
|
||||
`Channel.IconImageId`**, а не экранный логотип-оверлей (тот — полупрозрачный водяной знак в углу
|
||||
видео и как иконка канала смотрелся бы плохо). Задаётся в общих настройках канала картинкой из
|
||||
реестра изображений. Если не задана — плеер получает заглушку: фавикон сайта (`/favicon.svg`),
|
||||
чтобы в списке каналов не было пустого квадрата. Абсолютные адреса собираются из схемы/хоста
|
||||
запроса (внешний TLS → `X-Forwarded-*`), поэтому за корректно настроенным прокси ссылки идут по
|
||||
https.
|
||||
|
||||
Плеер не умеет ни Bearer, ни httpOnly-cookie — только открыть URL. Поэтому пропуск зашит прямо
|
||||
в ссылки файла долгим stream-токеном (`Storage:IptvTokenDays`, по умолчанию 30 суток), и его же
|
||||
принимают плейлист канала и сегменты — рядом с cookie, тем же форматом и той же проверкой подписи.
|
||||
|
||||
@@ -43,6 +43,7 @@ type ChannelSettingsBody = {
|
||||
name: string
|
||||
isEnabled: boolean
|
||||
fillerAssetId: string | null
|
||||
iconImageId: string | null
|
||||
}
|
||||
|
||||
export function updateChannelSettings(id: string, body: ChannelSettingsBody) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { imageUrl } from '@/features/admin/images/api'
|
||||
import { ImageGallery } from '@/features/admin/images/ImageGallery'
|
||||
import type { ChannelDto } from '@/shared/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
@@ -27,6 +29,8 @@ export function SettingsCard({
|
||||
const [name, setName] = useState(channel.name)
|
||||
const [isEnabled, setIsEnabled] = useState(channel.isEnabled)
|
||||
const [fillerAssetId, setFillerAssetId] = useState(channel.fillerAssetId ?? '')
|
||||
const [iconImageId, setIconImageId] = useState(channel.iconImageId ?? '')
|
||||
const [iconGalleryOpen, setIconGalleryOpen] = useState(false)
|
||||
const [number, setNumber] = useState(channel.number?.toString() ?? '')
|
||||
const [offsetHours, setOffsetHours] = useState(channel.utcOffsetMinutes / 60)
|
||||
// Начало вещательных суток приходит как «06:00:00» — в поле нужен формат «06:00».
|
||||
@@ -36,6 +40,7 @@ export function SettingsCard({
|
||||
setName(channel.name)
|
||||
setIsEnabled(channel.isEnabled)
|
||||
setFillerAssetId(channel.fillerAssetId ?? '')
|
||||
setIconImageId(channel.iconImageId ?? '')
|
||||
setNumber(channel.number?.toString() ?? '')
|
||||
setOffsetHours(channel.utcOffsetMinutes / 60)
|
||||
setDayStart(channel.dayStartTime.slice(0, 5))
|
||||
@@ -48,6 +53,7 @@ export function SettingsCard({
|
||||
name: name.trim(),
|
||||
isEnabled,
|
||||
fillerAssetId: fillerAssetId || null,
|
||||
iconImageId: iconImageId || null,
|
||||
})
|
||||
await updateChannelTime(channel.id, {
|
||||
number: number.trim() === '' ? null : Number(number),
|
||||
@@ -119,6 +125,35 @@ export function SettingsCard({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.iptvIcon')}</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-md border border-border bg-muted/30">
|
||||
{iconImageId ? (
|
||||
<img src={imageUrl(iconImageId)} alt="" className="h-full w-full object-contain" />
|
||||
) : (
|
||||
<span className="text-center text-[10px] leading-tight text-muted-foreground">
|
||||
{t('admin.channels.noIptvIcon')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => setIconGalleryOpen(true)}>
|
||||
{t('admin.channels.pickIptvIcon')}
|
||||
</Button>
|
||||
{iconImageId && (
|
||||
<Button size="sm" variant="ghost" onClick={() => setIconImageId('')}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.iptvIconHint')}</p>
|
||||
<ImageGallery
|
||||
open={iconGalleryOpen}
|
||||
onOpenChange={setIconGalleryOpen}
|
||||
category="Library"
|
||||
onSelect={(image) => setIconImageId(image.id)}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@@ -651,6 +651,8 @@ export type ChannelDto = {
|
||||
dayStartTime: string
|
||||
templateId: string | null
|
||||
fillerAssetId: string | null
|
||||
/** Иконка канала для внешних IPTV-плееров (M3U/XMLTV); null — отдаётся фавикон-заглушка. */
|
||||
iconImageId: string | null
|
||||
viewer: ViewerSettings
|
||||
}
|
||||
|
||||
|
||||
@@ -785,6 +785,11 @@ export const en = {
|
||||
settings: 'Settings',
|
||||
filler: 'Filler',
|
||||
noFiller: 'No filler',
|
||||
iptvIcon: 'IPTV icon',
|
||||
noIptvIcon: 'none',
|
||||
pickIptvIcon: 'Pick an icon',
|
||||
iptvIconHint:
|
||||
'Channel logo in M3U/EPG for external players. If unset, the site favicon is served.',
|
||||
noSchedule: 'Schedule not built yet',
|
||||
airToday: 'Today',
|
||||
airCount: 'Entries for the day: {{count}}',
|
||||
|
||||
@@ -780,6 +780,11 @@ export const ru = {
|
||||
settings: 'Настройки',
|
||||
filler: 'Заглушка',
|
||||
noFiller: 'Без заглушки',
|
||||
iptvIcon: 'Иконка для IPTV',
|
||||
noIptvIcon: 'нет',
|
||||
pickIptvIcon: 'Выбрать иконку',
|
||||
iptvIconHint:
|
||||
'Логотип канала в M3U/EPG для внешних плееров. Если не задан — отдаётся фавикон сайта.',
|
||||
noSchedule: 'Расписание ещё не построено',
|
||||
airToday: 'Сегодня',
|
||||
airCount: 'Записей за сутки: {{count}}',
|
||||
|
||||
Reference in New Issue
Block a user