Compare commits
3
Commits
37407a5ecd
...
b92a0d837e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b92a0d837e | ||
|
|
d7da0ff203 | ||
|
|
bfe62d7078 |
+22
-2
@@ -13,6 +13,8 @@ código e os commits seguem em inglês, como o resto do repositório.
|
|||||||
| Ir para a call clicando no nome do canal | `c70b0095` | Exigiu o `voiceStore` passar a guardar o espaço da call — antes ele não sabia onde a call estava assim que o usuário navegava para outro servidor |
|
| Ir para a call clicando no nome do canal | `c70b0095` | Exigiu o `voiceStore` passar a guardar o espaço da call — antes ele não sabia onde a call estava assim que o usuário navegava para outro servidor |
|
||||||
| Botão de GIF redesenhado | `20526e1b` | Contorno vazado com letras cheias, no lugar do bloco sólido |
|
| Botão de GIF redesenhado | `20526e1b` | Contorno vazado com letras cheias, no lugar do bloco sólido |
|
||||||
| Explorador de GIF no banner | `20526e1b` | Sem upload: banner já aceita URL absoluta no cliente e no servidor |
|
| Explorador de GIF no banner | `20526e1b` | Sem upload: banner já aceita URL absoluta no cliente e no servidor |
|
||||||
|
| Teste de microfone com retorno | `bfe62d70` | `AudioManager.startMicTest/stopMicTest`; devolve o microfone ao parar, com duas travas independentes |
|
||||||
|
| Bloco de atividade no perfil | `d7da0ff2` | `ProfileActivity`; inclui correção de validação de assets no servidor |
|
||||||
| Preview de perfil nos participantes da call | (ver git log) | O popout já existia e era aberto de 11 lugares; **nenhum era de voz**. Ligado nas linhas da lista de voz e no nome dos tiles da grade |
|
| Preview de perfil nos participantes da call | (ver git log) | O popout já existia e era aberto de 11 lugares; **nenhum era de voz**. Ligado nas linhas da lista de voz e no nome dos tiles da grade |
|
||||||
|
|
||||||
## Já existia no código (verificado, não construir de novo)
|
## Já existia no código (verificado, não construir de novo)
|
||||||
@@ -37,9 +39,8 @@ Ordem sugerida: as pequenas primeiro, as grandes uma de cada vez.
|
|||||||
|
|
||||||
| # | Feature | Tamanho | Observação técnica |
|
| # | Feature | Tamanho | Observação técnica |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 10 | Teste de voz com loopback | Média | `AudioManager.playTestTone()` já existe; falta capturar o mic e devolver no monitor, com "Stop Testing" |
|
|
||||||
| 6 | Favoritar GIFs + categorias | Grande | Precisa de tabela, migração drizzle e API para sincronizar entre dispositivos, como no Discord |
|
| 6 | Favoritar GIFs + categorias | Grande | Precisa de tabela, migração drizzle e API para sincronizar entre dispositivos, como no Discord |
|
||||||
| 8 | Atividade (Spotify etc.) | Grande | `activityStore` e `activityBridge` já existem, mas a detecção é via Electron; Spotify exige OAuth e presença via WebSocket |
|
| 8 | **Produtor** de atividade do Spotify | Grande | O consumo está pronto (`ProfileActivity` + pipeline completo). Falta algo que *gere* a atividade com faixa e artista — ver abaixo |
|
||||||
| 9 | Registro de auditoria | Grande | Schema + ganchos em cada mutação do servidor + interface |
|
| 9 | Registro de auditoria | Grande | Schema + ganchos em cada mutação do servidor + interface |
|
||||||
|
|
||||||
## Pendente — ideias aprovadas
|
## Pendente — ideias aprovadas
|
||||||
@@ -51,6 +52,25 @@ Ordem sugerida: as pequenas primeiro, as grandes uma de cada vez.
|
|||||||
| Watch party | Grande | O screen share do LiveKit já existe; falta sincronizar posição de reprodução entre participantes |
|
| Watch party | Grande | O screen share do LiveKit já existe; falta sincronizar posição de reprodução entre participantes |
|
||||||
| Emojis e stickers do grupo | Média | `UPLOAD_DIR` e o pipeline de upload já existem; falta tabela por espaço e resolução no render de mensagem |
|
| Emojis e stickers do grupo | Média | `UPLOAD_DIR` e o pipeline de upload já existem; falta tabela por espaço e resolução no render de mensagem |
|
||||||
|
|
||||||
|
## O que falta para o Spotify (#8)
|
||||||
|
|
||||||
|
O caminho de consumo está inteiro: tipo, store, WebSocket, validação no
|
||||||
|
servidor, relay de presença e agora o bloco no perfil. **Falta um produtor.**
|
||||||
|
|
||||||
|
Três opções, com custos bem diferentes:
|
||||||
|
|
||||||
|
1. **Entrada no dicionário do detector** (`activityDetector.ts` lê um JSON de
|
||||||
|
processos, e `listening` já é um tipo válido). Custo quase zero, mas dá
|
||||||
|
apenas "Listening to Spotify" — sem faixa nem artista — e **só no app
|
||||||
|
Electron**.
|
||||||
|
2. **Ler o título da janela do Spotify** no processo main do Electron. O título
|
||||||
|
é "Artista - Faixa", então preenche `details` e `state`. Ainda só desktop, e
|
||||||
|
sem capa nem duração.
|
||||||
|
3. **Spotify Web API com OAuth.** É a única que cobre quem usa pelo navegador —
|
||||||
|
que é a maioria do grupo — e a única que traz capa e progresso.
|
||||||
|
**Bloqueio:** exige registrar um app no dashboard do Spotify e obter
|
||||||
|
client id/secret. Isso é ação sua; eu não consigo fazer.
|
||||||
|
|
||||||
## Dependência que vale respeitar
|
## Dependência que vale respeitar
|
||||||
|
|
||||||
**Auditoria (#9) e Estatísticas compartilham o mesmo mecanismo**: uma tabela de
|
**Auditoria (#9) e Estatísticas compartilham o mesmo mecanismo**: uma tabela de
|
||||||
|
|||||||
@@ -473,9 +473,16 @@ function validateActivities(raw: unknown): Activity[] | null {
|
|||||||
if (obj.assets && typeof obj.assets === 'object') {
|
if (obj.assets && typeof obj.assets === 'object') {
|
||||||
const aObj = obj.assets as Record<string, unknown>;
|
const aObj = obj.assets as Record<string, unknown>;
|
||||||
const assets: ActivityAssets = {};
|
const assets: ActivityAssets = {};
|
||||||
if (typeof aObj.largeImage === 'string' && aObj.largeImage.length <= ACTIVITY_LIMITS.MAX_URL_LENGTH) assets.largeImage = aObj.largeImage;
|
// Image assets are rendered as <img src> by clients, so they get the same
|
||||||
|
// scheme check `url` above already has. Without it a client could point
|
||||||
|
// them at a host it controls and harvest the IP of everyone who opens
|
||||||
|
// that profile — and data: URIs would smuggle payloads through a field
|
||||||
|
// only length-checked.
|
||||||
|
if (typeof aObj.largeImage === 'string' && aObj.largeImage.length <= ACTIVITY_LIMITS.MAX_URL_LENGTH
|
||||||
|
&& isHttpUrl(aObj.largeImage)) assets.largeImage = aObj.largeImage;
|
||||||
if (typeof aObj.largeText === 'string' && aObj.largeText.length <= ACTIVITY_LIMITS.MAX_ASSET_TEXT_LENGTH) assets.largeText = aObj.largeText;
|
if (typeof aObj.largeText === 'string' && aObj.largeText.length <= ACTIVITY_LIMITS.MAX_ASSET_TEXT_LENGTH) assets.largeText = aObj.largeText;
|
||||||
if (typeof aObj.smallImage === 'string' && aObj.smallImage.length <= ACTIVITY_LIMITS.MAX_URL_LENGTH) assets.smallImage = aObj.smallImage;
|
if (typeof aObj.smallImage === 'string' && aObj.smallImage.length <= ACTIVITY_LIMITS.MAX_URL_LENGTH
|
||||||
|
&& isHttpUrl(aObj.smallImage)) assets.smallImage = aObj.smallImage;
|
||||||
if (typeof aObj.smallText === 'string' && aObj.smallText.length <= ACTIVITY_LIMITS.MAX_ASSET_TEXT_LENGTH) assets.smallText = aObj.smallText;
|
if (typeof aObj.smallText === 'string' && aObj.smallText.length <= ACTIVITY_LIMITS.MAX_ASSET_TEXT_LENGTH) assets.smallText = aObj.smallText;
|
||||||
if (Object.keys(assets).length > 0) activity.assets = assets;
|
if (Object.keys(assets).length > 0) activity.assets = assets;
|
||||||
}
|
}
|
||||||
@@ -485,6 +492,10 @@ function validateActivities(raw: unknown): Activity[] | null {
|
|||||||
return validated;
|
return validated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isHttpUrl(value: string): boolean {
|
||||||
|
return value.startsWith('https://') || value.startsWith('http://');
|
||||||
|
}
|
||||||
|
|
||||||
function handlePresenceUpdate(event: Record<string, unknown>, userId: string): void {
|
function handlePresenceUpdate(event: Record<string, unknown>, userId: string): void {
|
||||||
const status = event.status as string;
|
const status = event.status as string;
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ export class AudioManager {
|
|||||||
private rnnoiseReady = false;
|
private rnnoiseReady = false;
|
||||||
private keepAliveOscillator: OscillatorNode | null = null;
|
private keepAliveOscillator: OscillatorNode | null = null;
|
||||||
|
|
||||||
|
// Mic test (settings → Voice). See startMicTest().
|
||||||
|
private micTestGain: GainNode | null = null;
|
||||||
|
private micTestStream: MediaStream | null = null;
|
||||||
|
|
||||||
// Cached `getUserMedia` denial. After a NotAllowedError, subsequent
|
// Cached `getUserMedia` denial. After a NotAllowedError, subsequent
|
||||||
// `setInputDevice` calls (e.g. `useLiveKit.syncMic` racing the user's
|
// `setInputDevice` calls (e.g. `useLiveKit.syncMic` racing the user's
|
||||||
// tap on a denial prompt) re-throw the cached error WITHOUT issuing a
|
// tap on a denial prompt) re-throw the cached error WITHOUT issuing a
|
||||||
@@ -567,6 +571,65 @@ export class AudioManager {
|
|||||||
osc.stop(now + 0.45);
|
osc.stop(now + 0.45);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mic test: routes the processed input bus to the speakers so the user hears
|
||||||
|
* themselves, outside of any call.
|
||||||
|
*
|
||||||
|
* Settings deliberately never opened the mic on their own — the level meter
|
||||||
|
* only measures a stream that a call had already established. A mic test
|
||||||
|
* cannot honour that, so this is the one path that opens it, and
|
||||||
|
* `stopMicTest` hands it back rather than leaving the mic indicator lit.
|
||||||
|
*
|
||||||
|
* Returns false when the mic could not be opened (denied, unplugged).
|
||||||
|
*/
|
||||||
|
async startMicTest(): Promise<boolean> {
|
||||||
|
if (this.micTestGain) return true;
|
||||||
|
const ctx = this.ensureContext();
|
||||||
|
await this.resumeContext();
|
||||||
|
|
||||||
|
const hadStream = this.hasActiveStream();
|
||||||
|
if (!hadStream) {
|
||||||
|
const stream = await this.setInputDevice(this.currentInputDeviceId);
|
||||||
|
if (!stream) return false;
|
||||||
|
// Remember the exact stream we opened, so stopMicTest only ever stops
|
||||||
|
// that one — never a stream something else established meanwhile.
|
||||||
|
this.micTestStream = this.currentStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.micTestGain = ctx.createGain();
|
||||||
|
this.inputGain!.connect(this.micTestGain);
|
||||||
|
this.micTestGain.connect(this.getMasterOutput());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tears down the loopback.
|
||||||
|
*
|
||||||
|
* @param allowRelease Whether the mic may be handed back. Only the caller
|
||||||
|
* knows whether a call has started since the test began — AudioManager
|
||||||
|
* does not read stores — so releasing needs its consent as well as our own
|
||||||
|
* record that this test is what opened the stream.
|
||||||
|
*/
|
||||||
|
stopMicTest(allowRelease: boolean): void {
|
||||||
|
if (!this.micTestGain) return;
|
||||||
|
try { this.inputGain?.disconnect(this.micTestGain); } catch { /* graph already torn down */ }
|
||||||
|
try { this.micTestGain.disconnect(); } catch { /* already detached */ }
|
||||||
|
this.micTestGain = null;
|
||||||
|
|
||||||
|
if (allowRelease && this.micTestStream && this.currentStream === this.micTestStream) {
|
||||||
|
// Detach listeners before stopping (see `_setInputDeviceImpl`).
|
||||||
|
const tracks = this.currentStream.getTracks();
|
||||||
|
tracks.forEach(t => { t.onended = null; });
|
||||||
|
tracks.forEach(t => t.stop());
|
||||||
|
this.currentStream = null;
|
||||||
|
}
|
||||||
|
this.micTestStream = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
isMicTestActive(): boolean {
|
||||||
|
return this.micTestGain !== null;
|
||||||
|
}
|
||||||
|
|
||||||
getContext(): AudioContext | null {
|
getContext(): AudioContext | null {
|
||||||
return this.ctx;
|
return this.ctx;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import type { User } from '@backspace/shared';
|
import type { User } from '@backspace/shared';
|
||||||
import { Avatar } from '../ui/Avatar';
|
import { Avatar } from '../ui/Avatar';
|
||||||
|
import { ProfileActivity } from '../ui/ProfileActivity';
|
||||||
|
import { useActivityStore } from '../../stores/activityStore';
|
||||||
import { Username } from '../ui/Username';
|
import { Username } from '../ui/Username';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
import { useSpaceStore, getApiForOrigin, resolveUserOrigin } from '../../stores/spaceStore';
|
import { useSpaceStore, getApiForOrigin, resolveUserOrigin } from '../../stores/spaceStore';
|
||||||
@@ -149,6 +151,13 @@ export function UserProfileModal() {
|
|||||||
|
|
||||||
// Banner — use correct API client for remote users
|
// Banner — use correct API client for remote users
|
||||||
const profileApi = getApiForOrigin(userOrigin);
|
const profileApi = getApiForOrigin(userOrigin);
|
||||||
|
// Keyed by home id, matching every other activity consumer (ActivityPanel,
|
||||||
|
// MemberSidebar), so federated users resolve to the same record. The `?? []`
|
||||||
|
// stays OUTSIDE the selector: building it inside would hand zustand a fresh
|
||||||
|
// array reference every render and spin.
|
||||||
|
const activityList = useActivityStore((s) => s.userActivities.get(user.homeUserId ?? user.id));
|
||||||
|
const activities = activityList ?? [];
|
||||||
|
|
||||||
const bannerSrc = user.banner
|
const bannerSrc = user.banner
|
||||||
? (user.banner.startsWith('http') ? user.banner : profileApi.uploads.url(user.banner))
|
? (user.banner.startsWith('http') ? user.banner : profileApi.uploads.url(user.banner))
|
||||||
: null;
|
: null;
|
||||||
@@ -353,6 +362,9 @@ export function UserProfileModal() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Current activity — the "Listening to Spotify" block */}
|
||||||
|
<ProfileActivity activities={activities} />
|
||||||
|
|
||||||
{/* Member Since */}
|
{/* Member Since */}
|
||||||
<div>
|
<div>
|
||||||
<span className="text-[11px] uppercase tracking-wide font-semibold text-txt-tertiary">
|
<span className="text-[11px] uppercase tracking-wide font-semibold text-txt-tertiary">
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export function AudioInputSection() {
|
|||||||
// then join voice and expect the meter / resolved-default hint to come
|
// then join voice and expect the meter / resolved-default hint to come
|
||||||
// alive without reopening the panel.
|
// alive without reopening the panel.
|
||||||
const [audioCtxGen, setAudioCtxGen] = useState(0);
|
const [audioCtxGen, setAudioCtxGen] = useState(0);
|
||||||
|
const [micTesting, setMicTesting] = useState(false);
|
||||||
|
const [micTestError, setMicTestError] = useState('');
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const animFrameRef = useRef<number>(0);
|
const animFrameRef = useRef<number>(0);
|
||||||
|
|
||||||
@@ -77,7 +79,39 @@ export function AudioInputSection() {
|
|||||||
stopped = true;
|
stopped = true;
|
||||||
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
||||||
};
|
};
|
||||||
}, [permState, audioCtxGen]);
|
}, [permState, audioCtxGen, micTesting]);
|
||||||
|
|
||||||
|
// Subscribed (not a one-off getState) so the hint text below tracks the call
|
||||||
|
// state live. The release decision itself reads getState() at the moment of
|
||||||
|
// stopping, which is when it must be accurate.
|
||||||
|
const isLiveKitConnected = useVoiceStore((s) => s.isLiveKitConnected);
|
||||||
|
|
||||||
|
const toggleMicTest = async () => {
|
||||||
|
const am = AudioManager.getInstance();
|
||||||
|
if (micTesting) {
|
||||||
|
am.stopMicTest(!useVoiceStore.getState().isLiveKitConnected);
|
||||||
|
setMicTesting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMicTestError('');
|
||||||
|
const ok = await am.startMicTest();
|
||||||
|
if (!ok) {
|
||||||
|
setMicTestError('Could not open the microphone. Check the device and its permission.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMicTesting(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Leaving the panel mid-test must not leave the loopback running or the mic
|
||||||
|
// held open.
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
const am = AudioManager.getInstance();
|
||||||
|
if (am.isMicTestActive()) {
|
||||||
|
am.stopMicTest(!useVoiceStore.getState().isLiveKitConnected);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Track the resolved upstream deviceId for the "Currently using: X" hint.
|
// Track the resolved upstream deviceId for the "Currently using: X" hint.
|
||||||
// Re-runs on `audioCtxGen` because the resolved-default ID is only known
|
// Re-runs on `audioCtxGen` because the resolved-default ID is only known
|
||||||
@@ -213,9 +247,30 @@ export function AudioInputSection() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-txt-tertiary mt-1.5">
|
<div className="flex items-center gap-3 mt-3">
|
||||||
The level meter activates once you join a voice channel.
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void toggleMicTest()}
|
||||||
|
disabled={permState !== 'granted'}
|
||||||
|
className={`px-3 py-1.5 rounded-md text-[13px] font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||||
|
micTesting
|
||||||
|
? 'bg-interactive-muted text-txt-primary hover:brightness-110'
|
||||||
|
: 'bg-accent-primary text-white hover:brightness-110'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{micTesting ? 'Stop Testing' : "Let's Check"}
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-txt-tertiary">
|
||||||
|
{micTesting
|
||||||
|
? 'Playing your mic back to you — say something.'
|
||||||
|
: isLiveKitConnected
|
||||||
|
? 'The level meter is live while you are in a call.'
|
||||||
|
: 'Test your mic without joining a call.'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{micTestError && (
|
||||||
|
<div className="text-xs text-txt-danger mt-1.5">{micTestError}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SectionShell>
|
</SectionShell>
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { Activity } from '@backspace/shared';
|
||||||
|
import { getPrimaryActivity } from '@backspace/shared/src/activities.js';
|
||||||
|
|
||||||
|
interface ProfileActivityProps {
|
||||||
|
activities: Activity[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const VERB: Record<Activity['type'], string> = {
|
||||||
|
playing: 'Playing',
|
||||||
|
listening: 'Listening to',
|
||||||
|
watching: 'Watching',
|
||||||
|
streaming: 'Streaming',
|
||||||
|
custom: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatClock(ms: number): string {
|
||||||
|
const total = Math.max(0, Math.floor(ms / 1000));
|
||||||
|
const minutes = Math.floor(total / 60);
|
||||||
|
const seconds = total % 60;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours > 0) return `${hours}:${String(minutes % 60).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
||||||
|
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The activity block on the profile card — the "Listening to Spotify" panel.
|
||||||
|
*
|
||||||
|
* Deliberately richer than `ActivityCard` (which renders name + elapsed for
|
||||||
|
* compact list rows): here there is room for the artwork, the track and the
|
||||||
|
* artist, so it reads `details`, `state` and `assets` too. Every one of those
|
||||||
|
* is optional and the block degrades to just the name, which is all today's
|
||||||
|
* process-based detector supplies — the extra fields are what a Spotify
|
||||||
|
* producer would fill in.
|
||||||
|
*/
|
||||||
|
export function ProfileActivity({ activities }: ProfileActivityProps) {
|
||||||
|
const primary = getPrimaryActivity(activities);
|
||||||
|
const start = primary?.timestamps?.start;
|
||||||
|
const end = primary?.timestamps?.end;
|
||||||
|
|
||||||
|
// Re-render once a second only while there is a clock to advance.
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
useEffect(() => {
|
||||||
|
if (!start) return;
|
||||||
|
const id = setInterval(() => setNow(Date.now()), 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [start]);
|
||||||
|
|
||||||
|
if (!primary || primary.type === 'custom') return null;
|
||||||
|
|
||||||
|
const elapsed = start ? now - start : 0;
|
||||||
|
const duration = start && end ? end - start : 0;
|
||||||
|
const progress = duration > 0 ? Math.min(Math.max(elapsed / duration, 0), 1) : 0;
|
||||||
|
|
||||||
|
// The server restricts asset images to http(s); this mirrors that so a
|
||||||
|
// record stored before that check cannot inject another scheme.
|
||||||
|
const art = primary.assets?.largeImage;
|
||||||
|
const artSrc = art && (art.startsWith('https://') || art.startsWith('http://')) ? art : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span className="text-[11px] uppercase tracking-wide font-semibold text-txt-tertiary">
|
||||||
|
{VERB[primary.type]} {primary.name}
|
||||||
|
</span>
|
||||||
|
<div className="mt-2 flex gap-3 rounded-lg bg-surface-elevated/40 p-2.5">
|
||||||
|
{artSrc && (
|
||||||
|
<img
|
||||||
|
src={artSrc}
|
||||||
|
alt={primary.assets?.largeText ?? ''}
|
||||||
|
className="w-[60px] h-[60px] rounded object-cover flex-shrink-0"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{primary.details && (
|
||||||
|
<div className="text-[13px] font-semibold text-txt-primary truncate">
|
||||||
|
{primary.details}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{primary.state && (
|
||||||
|
<div className="text-[12px] text-txt-secondary truncate">{primary.state}</div>
|
||||||
|
)}
|
||||||
|
{duration > 0 ? (
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="h-[3px] rounded-full bg-interactive-muted overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-txt-primary rounded-full"
|
||||||
|
style={{ width: `${progress * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-[10px] text-txt-tertiary mt-1 tabular-nums">
|
||||||
|
<span>{formatClock(elapsed)}</span>
|
||||||
|
<span>{formatClock(duration)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : start ? (
|
||||||
|
<div className="text-[11px] text-txt-tertiary mt-1 tabular-nums">
|
||||||
|
{formatClock(elapsed)} elapsed
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user