feat(channel): add logo support with position and opacity settings

This commit is contained in:
2026-03-14 02:27:16 +01:00
parent e610c23fea
commit da714840ee
11 changed files with 204 additions and 6 deletions

View File

@@ -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,
} }

View File

@@ -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?;

View File

@@ -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,
} }

View File

@@ -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")]

View File

@@ -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";

View File

@@ -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)

View File

@@ -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;

View File

@@ -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">

View File

@@ -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(

View File

@@ -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

View File

@@ -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