feat(channel): add logo support with position and opacity settings
This commit is contained in:
@@ -76,6 +76,10 @@ pub struct UpdateChannelRequest {
|
|||||||
pub access_mode: Option<domain::AccessMode>,
|
pub access_mode: Option<domain::AccessMode>,
|
||||||
/// Empty string clears the password; non-empty re-hashes.
|
/// Empty string clears the password; non-empty re-hashes.
|
||||||
pub access_password: Option<String>,
|
pub access_password: Option<String>,
|
||||||
|
/// `Some(None)` = clear logo, `Some(Some(url))` = set logo, `None` = unchanged.
|
||||||
|
pub logo: Option<Option<String>>,
|
||||||
|
pub logo_position: Option<domain::LogoPosition>,
|
||||||
|
pub logo_opacity: Option<f32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -89,6 +93,9 @@ pub struct ChannelResponse {
|
|||||||
pub recycle_policy: domain::RecyclePolicy,
|
pub recycle_policy: domain::RecyclePolicy,
|
||||||
pub auto_schedule: bool,
|
pub auto_schedule: bool,
|
||||||
pub access_mode: domain::AccessMode,
|
pub access_mode: domain::AccessMode,
|
||||||
|
pub logo: Option<String>,
|
||||||
|
pub logo_position: domain::LogoPosition,
|
||||||
|
pub logo_opacity: f32,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
@@ -105,6 +112,9 @@ impl From<domain::Channel> for ChannelResponse {
|
|||||||
recycle_policy: c.recycle_policy,
|
recycle_policy: c.recycle_policy,
|
||||||
auto_schedule: c.auto_schedule,
|
auto_schedule: c.auto_schedule,
|
||||||
access_mode: c.access_mode,
|
access_mode: c.access_mode,
|
||||||
|
logo: c.logo,
|
||||||
|
logo_position: c.logo_position,
|
||||||
|
logo_opacity: c.logo_opacity,
|
||||||
created_at: c.created_at,
|
created_at: c.created_at,
|
||||||
updated_at: c.updated_at,
|
updated_at: c.updated_at,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,6 +101,15 @@ pub(super) async fn update_channel(
|
|||||||
channel.access_password_hash = Some(infra::auth::hash_password(&pw));
|
channel.access_password_hash = Some(infra::auth::hash_password(&pw));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(logo) = payload.logo {
|
||||||
|
channel.logo = logo;
|
||||||
|
}
|
||||||
|
if let Some(pos) = payload.logo_position {
|
||||||
|
channel.logo_position = pos;
|
||||||
|
}
|
||||||
|
if let Some(opacity) = payload.logo_opacity {
|
||||||
|
channel.logo_opacity = opacity.clamp(0.0, 1.0);
|
||||||
|
}
|
||||||
channel.updated_at = Utc::now();
|
channel.updated_at = Utc::now();
|
||||||
|
|
||||||
let channel = state.channel_service.update(channel).await?;
|
let channel = state.channel_service.update(channel).await?;
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ use serde::{Deserialize, Serialize};
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::value_objects::{
|
use crate::value_objects::{
|
||||||
AccessMode, BlockId, ChannelId, ContentType, FillStrategy, MediaFilter, MediaItemId,
|
AccessMode, BlockId, ChannelId, ContentType, FillStrategy, LogoPosition, MediaFilter,
|
||||||
RecyclePolicy, SlotId,
|
MediaItemId, RecyclePolicy, SlotId,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// A user in the system.
|
/// A user in the system.
|
||||||
@@ -85,6 +85,9 @@ pub struct Channel {
|
|||||||
pub auto_schedule: bool,
|
pub auto_schedule: bool,
|
||||||
pub access_mode: AccessMode,
|
pub access_mode: AccessMode,
|
||||||
pub access_password_hash: Option<String>,
|
pub access_password_hash: Option<String>,
|
||||||
|
pub logo: Option<String>,
|
||||||
|
pub logo_position: LogoPosition,
|
||||||
|
pub logo_opacity: f32,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
@@ -107,6 +110,9 @@ impl Channel {
|
|||||||
auto_schedule: false,
|
auto_schedule: false,
|
||||||
access_mode: AccessMode::default(),
|
access_mode: AccessMode::default(),
|
||||||
access_password_hash: None,
|
access_password_hash: None,
|
||||||
|
logo: None,
|
||||||
|
logo_position: LogoPosition::default(),
|
||||||
|
logo_opacity: 1.0,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|
||||||
|
/// Position of the channel logo watermark overlay.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum LogoPosition {
|
||||||
|
TopLeft,
|
||||||
|
#[default]
|
||||||
|
TopRight,
|
||||||
|
BottomLeft,
|
||||||
|
BottomRight,
|
||||||
|
}
|
||||||
|
|
||||||
/// Controls who can view a channel's broadcast and stream.
|
/// Controls who can view a channel's broadcast and stream.
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use chrono::{DateTime, Utc};
|
|||||||
use sqlx::FromRow;
|
use sqlx::FromRow;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use domain::{AccessMode, Channel, ChannelId, DomainError, RecyclePolicy, ScheduleConfig, UserId};
|
use domain::{AccessMode, Channel, ChannelId, DomainError, LogoPosition, RecyclePolicy, ScheduleConfig, UserId};
|
||||||
|
|
||||||
#[derive(Debug, FromRow)]
|
#[derive(Debug, FromRow)]
|
||||||
pub(super) struct ChannelRow {
|
pub(super) struct ChannelRow {
|
||||||
@@ -16,6 +16,9 @@ pub(super) struct ChannelRow {
|
|||||||
pub auto_schedule: i64,
|
pub auto_schedule: i64,
|
||||||
pub access_mode: String,
|
pub access_mode: String,
|
||||||
pub access_password_hash: Option<String>,
|
pub access_password_hash: Option<String>,
|
||||||
|
pub logo: Option<String>,
|
||||||
|
pub logo_position: String,
|
||||||
|
pub logo_opacity: f32,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
@@ -51,6 +54,11 @@ impl TryFrom<ChannelRow> for Channel {
|
|||||||
)
|
)
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let logo_position: LogoPosition = serde_json::from_value(
|
||||||
|
serde_json::Value::String(row.logo_position),
|
||||||
|
)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
Ok(Channel {
|
Ok(Channel {
|
||||||
id,
|
id,
|
||||||
owner_id,
|
owner_id,
|
||||||
@@ -62,6 +70,9 @@ impl TryFrom<ChannelRow> for Channel {
|
|||||||
auto_schedule: row.auto_schedule != 0,
|
auto_schedule: row.auto_schedule != 0,
|
||||||
access_mode,
|
access_mode,
|
||||||
access_password_hash: row.access_password_hash,
|
access_password_hash: row.access_password_hash,
|
||||||
|
logo: row.logo,
|
||||||
|
logo_position,
|
||||||
|
logo_opacity: row.logo_opacity,
|
||||||
created_at: parse_dt(&row.created_at)?,
|
created_at: parse_dt(&row.created_at)?,
|
||||||
updated_at: parse_dt(&row.updated_at)?,
|
updated_at: parse_dt(&row.updated_at)?,
|
||||||
})
|
})
|
||||||
@@ -69,4 +80,4 @@ impl TryFrom<ChannelRow> for Channel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) const SELECT_COLS: &str =
|
pub(super) const SELECT_COLS: &str =
|
||||||
"id, owner_id, name, description, timezone, schedule_config, recycle_policy, auto_schedule, access_mode, access_password_hash, created_at, updated_at";
|
"id, owner_id, name, description, timezone, schedule_config, recycle_policy, auto_schedule, access_mode, access_password_hash, logo, logo_position, logo_opacity, created_at, updated_at";
|
||||||
|
|||||||
@@ -63,11 +63,16 @@ impl ChannelRepository for SqliteChannelRepository {
|
|||||||
.and_then(|v| v.as_str().map(str::to_owned))
|
.and_then(|v| v.as_str().map(str::to_owned))
|
||||||
.unwrap_or_else(|| "public".to_owned());
|
.unwrap_or_else(|| "public".to_owned());
|
||||||
|
|
||||||
|
let logo_position = serde_json::to_value(&channel.logo_position)
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.as_str().map(str::to_owned))
|
||||||
|
.unwrap_or_else(|| "top_right".to_owned());
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO channels
|
INSERT INTO channels
|
||||||
(id, owner_id, name, description, timezone, schedule_config, recycle_policy, auto_schedule, access_mode, access_password_hash, created_at, updated_at)
|
(id, owner_id, name, description, timezone, schedule_config, recycle_policy, auto_schedule, access_mode, access_password_hash, logo, logo_position, logo_opacity, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
name = excluded.name,
|
name = excluded.name,
|
||||||
description = excluded.description,
|
description = excluded.description,
|
||||||
@@ -77,6 +82,9 @@ impl ChannelRepository for SqliteChannelRepository {
|
|||||||
auto_schedule = excluded.auto_schedule,
|
auto_schedule = excluded.auto_schedule,
|
||||||
access_mode = excluded.access_mode,
|
access_mode = excluded.access_mode,
|
||||||
access_password_hash = excluded.access_password_hash,
|
access_password_hash = excluded.access_password_hash,
|
||||||
|
logo = excluded.logo,
|
||||||
|
logo_position = excluded.logo_position,
|
||||||
|
logo_opacity = excluded.logo_opacity,
|
||||||
updated_at = excluded.updated_at
|
updated_at = excluded.updated_at
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
@@ -90,6 +98,9 @@ impl ChannelRepository for SqliteChannelRepository {
|
|||||||
.bind(channel.auto_schedule as i64)
|
.bind(channel.auto_schedule as i64)
|
||||||
.bind(&access_mode)
|
.bind(&access_mode)
|
||||||
.bind(&channel.access_password_hash)
|
.bind(&channel.access_password_hash)
|
||||||
|
.bind(&channel.logo)
|
||||||
|
.bind(&logo_position)
|
||||||
|
.bind(channel.logo_opacity)
|
||||||
.bind(channel.created_at.to_rfc3339())
|
.bind(channel.created_at.to_rfc3339())
|
||||||
.bind(channel.updated_at.to_rfc3339())
|
.bind(channel.updated_at.to_rfc3339())
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE channels ADD COLUMN logo TEXT;
|
||||||
|
ALTER TABLE channels ADD COLUMN logo_position TEXT NOT NULL DEFAULT 'top_right';
|
||||||
|
ALTER TABLE channels ADD COLUMN logo_opacity REAL NOT NULL DEFAULT 1.0;
|
||||||
@@ -13,6 +13,7 @@ import { useCollections, useSeries, useGenres } from "@/hooks/use-library";
|
|||||||
import type {
|
import type {
|
||||||
AccessMode,
|
AccessMode,
|
||||||
ChannelResponse,
|
ChannelResponse,
|
||||||
|
LogoPosition,
|
||||||
ProgrammingBlock,
|
ProgrammingBlock,
|
||||||
BlockContent,
|
BlockContent,
|
||||||
FillStrategy,
|
FillStrategy,
|
||||||
@@ -711,6 +712,9 @@ interface EditChannelSheetProps {
|
|||||||
auto_schedule: boolean;
|
auto_schedule: boolean;
|
||||||
access_mode?: AccessMode;
|
access_mode?: AccessMode;
|
||||||
access_password?: string;
|
access_password?: string;
|
||||||
|
logo?: string | null;
|
||||||
|
logo_position?: LogoPosition;
|
||||||
|
logo_opacity?: number;
|
||||||
},
|
},
|
||||||
) => void;
|
) => void;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
@@ -737,8 +741,12 @@ export function EditChannelSheet({
|
|||||||
const [autoSchedule, setAutoSchedule] = useState(false);
|
const [autoSchedule, setAutoSchedule] = useState(false);
|
||||||
const [accessMode, setAccessMode] = useState<AccessMode>("public");
|
const [accessMode, setAccessMode] = useState<AccessMode>("public");
|
||||||
const [accessPassword, setAccessPassword] = useState("");
|
const [accessPassword, setAccessPassword] = useState("");
|
||||||
|
const [logo, setLogo] = useState<string | null>(null);
|
||||||
|
const [logoPosition, setLogoPosition] = useState<LogoPosition>("top_right");
|
||||||
|
const [logoOpacity, setLogoOpacity] = useState(100);
|
||||||
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
|
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
|
||||||
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (channel) {
|
if (channel) {
|
||||||
@@ -750,6 +758,9 @@ export function EditChannelSheet({
|
|||||||
setAutoSchedule(channel.auto_schedule);
|
setAutoSchedule(channel.auto_schedule);
|
||||||
setAccessMode(channel.access_mode ?? "public");
|
setAccessMode(channel.access_mode ?? "public");
|
||||||
setAccessPassword("");
|
setAccessPassword("");
|
||||||
|
setLogo(channel.logo ?? null);
|
||||||
|
setLogoPosition(channel.logo_position ?? "top_right");
|
||||||
|
setLogoOpacity(Math.round((channel.logo_opacity ?? 1) * 100));
|
||||||
setSelectedBlockId(null);
|
setSelectedBlockId(null);
|
||||||
setFieldErrors({});
|
setFieldErrors({});
|
||||||
}
|
}
|
||||||
@@ -779,6 +790,9 @@ export function EditChannelSheet({
|
|||||||
auto_schedule: autoSchedule,
|
auto_schedule: autoSchedule,
|
||||||
access_mode: accessMode !== "public" ? accessMode : "public",
|
access_mode: accessMode !== "public" ? accessMode : "public",
|
||||||
access_password: accessPassword || "",
|
access_password: accessPassword || "",
|
||||||
|
logo: logo,
|
||||||
|
logo_position: logoPosition,
|
||||||
|
logo_opacity: logoOpacity / 100,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -883,6 +897,92 @@ export function EditChannelSheet({
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Logo */}
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">Logo</h3>
|
||||||
|
|
||||||
|
<div className="space-y-3 rounded-md border border-zinc-700/50 bg-zinc-800/50 p-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
className="border-zinc-700 text-zinc-300 hover:text-zinc-100"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
Upload image
|
||||||
|
</Button>
|
||||||
|
{logo && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
className="text-zinc-500 hover:text-red-400"
|
||||||
|
onClick={() => setLogo(null)}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*,.svg"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (ev) => setLogo(ev.target?.result as string ?? null);
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
e.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field label="URL or SVG markup" hint="Paste a remote URL, data URI, or inline SVG">
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={logo ?? ""}
|
||||||
|
onChange={(e) => setLogo(e.target.value || null)}
|
||||||
|
placeholder="https://… or <svg>…</svg>"
|
||||||
|
className="w-full resize-none rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 font-mono text-xs text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{logo && (
|
||||||
|
<div className="flex items-center justify-center rounded-md border border-zinc-700 bg-zinc-900 p-3">
|
||||||
|
{logo.trimStart().startsWith("<") ? (
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: logo }} className="h-12 w-auto" />
|
||||||
|
) : (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={logo} alt="" className="h-12 w-auto object-contain" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field label="Position">
|
||||||
|
<NativeSelect value={logoPosition} onChange={(v) => setLogoPosition(v as LogoPosition)}>
|
||||||
|
<option value="top_left">Top left</option>
|
||||||
|
<option value="top_right">Top right</option>
|
||||||
|
<option value="bottom_left">Bottom left</option>
|
||||||
|
<option value="bottom_right">Bottom right</option>
|
||||||
|
</NativeSelect>
|
||||||
|
</Field>
|
||||||
|
<Field label={`Opacity (${logoOpacity}%)`}>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={logoOpacity}
|
||||||
|
onChange={(e) => setLogoOpacity(Number(e.target.value))}
|
||||||
|
className="w-full accent-zinc-400 mt-2"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Programming blocks */}
|
{/* Programming blocks */}
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|||||||
@@ -148,6 +148,9 @@ export default function DashboardPage() {
|
|||||||
auto_schedule: boolean;
|
auto_schedule: boolean;
|
||||||
access_mode?: import("@/lib/types").AccessMode;
|
access_mode?: import("@/lib/types").AccessMode;
|
||||||
access_password?: string;
|
access_password?: string;
|
||||||
|
logo?: string | null;
|
||||||
|
logo_position?: import("@/lib/types").LogoPosition;
|
||||||
|
logo_opacity?: number;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
updateChannel.mutate(
|
updateChannel.mutate(
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
ChannelPasswordModal,
|
ChannelPasswordModal,
|
||||||
} from "./components";
|
} from "./components";
|
||||||
import type { SubtitleTrack } from "./components/video-player";
|
import type { SubtitleTrack } from "./components/video-player";
|
||||||
|
import type { LogoPosition } from "@/lib/types";
|
||||||
import { Maximize2, Minimize2, Volume1, Volume2, VolumeX } from "lucide-react";
|
import { Maximize2, Minimize2, Volume1, Volume2, VolumeX } from "lucide-react";
|
||||||
import { useAuthContext } from "@/context/auth-context";
|
import { useAuthContext } from "@/context/auth-context";
|
||||||
import { useChannels, useCurrentBroadcast, useEpg } from "@/hooks/use-channels";
|
import { useChannels, useCurrentBroadcast, useEpg } from "@/hooks/use-channels";
|
||||||
@@ -33,6 +34,15 @@ import {
|
|||||||
const IDLE_TIMEOUT_MS = 3500;
|
const IDLE_TIMEOUT_MS = 3500;
|
||||||
const BANNER_THRESHOLD = 80; // show "up next" when progress ≥ this %
|
const BANNER_THRESHOLD = 80; // show "up next" when progress ≥ this %
|
||||||
|
|
||||||
|
function logoPositionClass(pos?: LogoPosition) {
|
||||||
|
switch (pos) {
|
||||||
|
case "top_left": return "top-0 left-0";
|
||||||
|
case "bottom_left": return "bottom-0 left-0";
|
||||||
|
case "bottom_right":return "bottom-0 right-0";
|
||||||
|
default: return "top-0 right-0";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Page
|
// Page
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -499,6 +509,21 @@ function TvPageContent() {
|
|||||||
{/* ── Base layer ─────────────────────────────────────────────── */}
|
{/* ── Base layer ─────────────────────────────────────────────── */}
|
||||||
<div className="absolute inset-0">{renderBase()}</div>
|
<div className="absolute inset-0">{renderBase()}</div>
|
||||||
|
|
||||||
|
{/* ── Logo watermark — always visible, not tied to idle state ── */}
|
||||||
|
{channel?.logo && (
|
||||||
|
<div
|
||||||
|
className={`pointer-events-none absolute z-10 p-3 ${logoPositionClass(channel.logo_position)}`}
|
||||||
|
style={{ opacity: channel.logo_opacity ?? 1 }}
|
||||||
|
>
|
||||||
|
{channel.logo.trimStart().startsWith("<") ? (
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: channel.logo }} className="h-12 w-auto" />
|
||||||
|
) : (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={channel.logo} alt="" className="h-12 w-auto object-contain" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Channel password modal ──────────────────────────────────── */}
|
{/* ── Channel password modal ──────────────────────────────────── */}
|
||||||
{showChannelPasswordModal && (
|
{showChannelPasswordModal && (
|
||||||
<ChannelPasswordModal
|
<ChannelPasswordModal
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ export type ContentType = "movie" | "episode" | "short";
|
|||||||
|
|
||||||
export type AccessMode = "public" | "password_protected" | "account_required" | "owner_only";
|
export type AccessMode = "public" | "password_protected" | "account_required" | "owner_only";
|
||||||
|
|
||||||
|
export type LogoPosition = "top_left" | "top_right" | "bottom_left" | "bottom_right";
|
||||||
|
|
||||||
export type FillStrategy = "best_fit" | "sequential" | "random";
|
export type FillStrategy = "best_fit" | "sequential" | "random";
|
||||||
|
|
||||||
export interface MediaFilter {
|
export interface MediaFilter {
|
||||||
@@ -110,6 +112,9 @@ export interface ChannelResponse {
|
|||||||
recycle_policy: RecyclePolicy;
|
recycle_policy: RecyclePolicy;
|
||||||
auto_schedule: boolean;
|
auto_schedule: boolean;
|
||||||
access_mode: AccessMode;
|
access_mode: AccessMode;
|
||||||
|
logo?: string | null;
|
||||||
|
logo_position: LogoPosition;
|
||||||
|
logo_opacity: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -132,6 +137,10 @@ export interface UpdateChannelRequest {
|
|||||||
access_mode?: AccessMode;
|
access_mode?: AccessMode;
|
||||||
/** Empty string clears the password. */
|
/** Empty string clears the password. */
|
||||||
access_password?: string;
|
access_password?: string;
|
||||||
|
/** null = clear logo */
|
||||||
|
logo?: string | null;
|
||||||
|
logo_position?: LogoPosition;
|
||||||
|
logo_opacity?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Media & Schedule
|
// Media & Schedule
|
||||||
|
|||||||
Reference in New Issue
Block a user