fix(web): polish social UI and prevent doubled shared audio
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m20s
release / desktop (Linux и Windows в релиз) (push) Successful in 19m22s
ci / ts / ts (packages + web) (push) Successful in 3m9s
release / release (версия и заметки) (push) Successful in 10s
release / android (APK в релиз) (push) Successful in 4m7s
release / prune (старые файлы и прогоны) (push) Successful in 11s

This commit is contained in:
mrleo1nid committed 2026-10-23 19:54:10 +03:00
1 parent 6450027b88
commit cb22f15912
17 files changed
+403 -61

No files matched your search

+18 -4
View File
@@ -1,8 +1,9 @@
import axe from 'axe-core';
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
for (const width of [1280, 1920]) { for (const { width, height, light } of [{ width: 1280, height: 900, light: false }, { width: 1920, height: 900, light: false }, { width: 1024, height: 600, light: false }, { width: 1280, height: 720, light: true }]) {
test(`mini chat keeps the page and draft at ${width}px`, async ({ page }, info) => { test(`mini chat keeps the page and draft at ${width}x${height} ${light ? 'light' : 'dark'}`, async ({ page }, info) => {
await page.setViewportSize({ width, height: 900 }); await page.setViewportSize({ width, height });
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru')); await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
await page.goto('/login'); await page.goto('/login');
await page.getByLabel('Логин', { exact: true }).fill('alice'); await page.getByLabel('Логин', { exact: true }).fill('alice');
@@ -15,11 +16,13 @@ for (const width of [1280, 1920]) {
await expect(page.getByRole('button', { name: 'Остров', exact: true })).toHaveAttribute('aria-pressed', 'true'); await expect(page.getByRole('button', { name: 'Остров', exact: true })).toHaveAttribute('aria-pressed', 'true');
await page.getByRole('link', { name: 'Главная', exact: true }).first().click(); await page.getByRole('link', { name: 'Главная', exact: true }).first().click();
} }
if (light) await page.evaluate(() => { document.documentElement.dataset.theme = 'light'; });
const dock = page.getByRole('complementary', { name: 'Друзья и чат', exact: true }); const dock = page.getByRole('complementary', { name: 'Друзья и чат', exact: true });
const toggle = dock.getByRole('button', { name: 'Друзья и чат', exact: true }); const toggle = dock.getByRole('button', { name: 'Друзья и чат', exact: true });
await toggle.click(); await toggle.click();
const panel = dock.getByRole('region', { name: 'Друзья и общение', exact: true }); const panel = dock.getByRole('region', { name: 'Друзья и общение', exact: true });
await panel.getByRole('textbox', { name: 'Найти диалог', exact: true }).fill('moderator'); await panel.getByRole('textbox', { name: 'Найти диалог', exact: true }).fill('moderator');
await expect(panel.getByRole('textbox', { name: 'Найти диалог', exact: true })).toHaveCSS('outline-style', 'none');
await panel.getByRole('button', { name: /moderator/ }).click(); await panel.getByRole('button', { name: /moderator/ }).click();
const composer = panel.getByRole('textbox', { name: 'Сообщение', exact: true }); const composer = panel.getByRole('textbox', { name: 'Сообщение', exact: true });
await composer.fill('Черновик из мини-чата'); await composer.fill('Черновик из мини-чата');
@@ -40,6 +43,17 @@ for (const width of [1280, 1920]) {
expect(panelBox.x).toBeGreaterThanOrEqual(width - 640); expect(panelBox.x).toBeGreaterThanOrEqual(width - 640);
expect(panelBox.y).toBeGreaterThanOrEqual(60); expect(panelBox.y).toBeGreaterThanOrEqual(60);
expect(panelBox.y + panelBox.height).toBeLessThan(playerBox.y); expect(panelBox.y + panelBox.height).toBeLessThan(playerBox.y);
await panel.evaluate(async element => {
await Promise.all(element.getAnimations({ subtree: true })
.filter(animation => animation.effect?.getTiming().iterations !== Infinity)
.map(animation => animation.finished.catch(() => undefined)));
});
await page.addScriptTag({ content: axe.source });
const violations = await page.evaluate(async (selector) => {
const result = await (window as unknown as { axe: typeof axe }).axe.run(document.querySelector(selector)!, { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa'] } });
return result.violations.map(violation => ({ id: violation.id, nodes: violation.nodes.map(node => ({ target: node.target, summary: node.failureSummary })) }));
}, '[id="mini-social-panel"]');
expect(violations).toEqual([]);
await page.screenshot({ path: info.outputPath('mini-chat.png'), fullPage: true }); await page.screenshot({ path: info.outputPath('mini-chat.png'), fullPage: true });
await composer.fill('Продолжить в полном чате'); await composer.fill('Продолжить в полном чате');
await composer.press('Escape'); await composer.press('Escape');
@@ -48,7 +62,7 @@ for (const width of [1280, 1920]) {
await expect(composer).toHaveValue('Продолжить в полном чате'); await expect(composer).toHaveValue('Продолжить в полном чате');
await page.setViewportSize({ width: 800, height: 900 }); await page.setViewportSize({ width: 800, height: 900 });
await expect(dock).toHaveCount(0); await expect(dock).toHaveCount(0);
await page.setViewportSize({ width, height: 900 }); await page.setViewportSize({ width, height });
await expect(composer).toHaveValue('Продолжить в полном чате'); await expect(composer).toHaveValue('Продолжить в полном чате');
await panel.getByRole('button', { name: 'Открыть раздел', exact: true }).click(); await panel.getByRole('button', { name: 'Открыть раздел', exact: true }).click();
await expect(page).toHaveURL(/\/friends\?/); await expect(page).toHaveURL(/\/friends\?/);
+45
View File
@@ -0,0 +1,45 @@
import axe from 'axe-core';
import { expect, test } from '@playwright/test';
for (const width of [390, 1280]) {
test(`notification history scrolls inside the dialog at ${width}px`, async ({ page }, info) => {
await page.setViewportSize({ width, height: 900 });
await page.addInitScript(() => {
localStorage.setItem('pipu.locale', 'ru');
localStorage.setItem('pipu.e2e.notifications', '1');
});
await page.goto('/login');
await page.getByLabel('Логин', { exact: true }).fill('alice');
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill('alice-password');
await page.getByRole('button', { name: 'Войти', exact: true }).click();
await page.getByRole('button', { name: 'Меню профиля', exact: true }).click();
await page.getByRole('menuitem', { name: 'Уведомления 4', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Уведомления', exact: true });
const list = dialog.getByRole('region', { name: 'Уведомления', exact: true });
await expect(list.getByRole('article')).toHaveCount(28);
const box = (await dialog.boundingBox())!;
expect(box.height).toBeLessThanOrEqual(680);
expect(box.width).toBeLessThanOrEqual(Math.min(600, width - (width < 768 ? 24 : 32)));
const close = dialog.getByRole('button', { name: 'Закрыть', exact: true });
const closeBefore = (await close.boundingBox())!;
await list.evaluate(element => { element.scrollTop = element.scrollHeight; });
await expect(close).toBeVisible();
expect((await close.boundingBox())!.y).toBe(closeBefore.y);
await list.evaluate(element => { element.scrollTop = 0; });
await page.addScriptTag({ content: axe.source });
const violations = await page.evaluate(async (selector) => {
const result = await (window as unknown as { axe: typeof axe }).axe.run(document.querySelector(selector)!, { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa'] } });
return result.violations.map(violation => ({ id: violation.id, nodes: violation.nodes.map(node => node.target) }));
}, '[role="dialog"]');
expect(violations).toEqual([]);
await page.screenshot({ path: info.outputPath('notifications.png'), fullPage: true });
await dialog.getByRole('button', { name: 'Непрочитанные 4', exact: true }).click();
await expect(list.getByRole('article')).toHaveCount(4);
await list.getByRole('button', { name: 'Прочитано', exact: true }).first().click();
await expect(list.getByRole('article')).toHaveCount(3);
await dialog.getByRole('button', { name: 'Все', exact: true }).click();
await expect(list.getByRole('article')).toHaveCount(28);
await close.click();
await expect(dialog).toBeHidden();
});
}
+43
View File
@@ -0,0 +1,43 @@
import axe from 'axe-core';
import { expect, test } from '@playwright/test';
for (const { width, light } of [{ width: 390, light: false }, { width: 1280, light: false }, { width: 1280, light: true }]) {
test(`social profile settings at ${width}px ${light ? 'light' : 'dark'}`, async ({ page }, info) => {
await page.setViewportSize({ width, height: 900 });
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
await page.goto('/login');
await page.getByLabel('Логин', { exact: true }).fill('alice');
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill('alice-password');
await page.getByRole('button', { name: 'Войти', exact: true }).click();
if (light) await page.evaluate(() => { document.documentElement.dataset.theme = 'light'; });
await page.getByRole('button', { name: 'Меню профиля', exact: true }).click();
await expect(page.getByRole('menuitem', { name: 'Уведомления', exact: true })).toBeVisible();
await page.getByRole('menuitem', { name: 'Моя музыка скрыта', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Приватность и общение', exact: true });
const visibility = dialog.getByRole('group', { name: 'Кто видит мою музыку', exact: true });
const friends = visibility.getByRole('button', { name: 'Друзьям', exact: true });
await friends.click();
await expect(friends).toHaveAttribute('aria-pressed', 'true');
await expect(friends).toBeEnabled();
await expect(friends).toBeFocused();
await dialog.getByText('Слушать вместе', { exact: true }).click();
await expect(dialog.getByRole('group', { name: 'Принимать запросы и приглашения от', exact: true })).toBeVisible();
expect((await dialog.boundingBox())!.width).toBeLessThanOrEqual(width - 24);
await expect.poll(() => dialog.evaluate(element => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1);
await page.addScriptTag({ content: axe.source });
const violations = await page.evaluate(async () => {
const result = await (window as unknown as { axe: typeof axe }).axe.run(document.querySelector('[role="dialog"]')!, { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa'] } });
return result.violations.map(violation => ({ id: violation.id, nodes: violation.nodes.map(node => node.target) }));
});
expect(violations).toEqual([]);
await page.screenshot({ path: info.outputPath('privacy.png'), fullPage: true });
await dialog.getByRole('button', { name: 'Закрыть', exact: true }).click();
if (width < 768) await page.getByRole('button', { name: 'Ещё', exact: true }).click();
await page.getByRole('link', { name: 'Друзья и общение', exact: true }).click();
const heading = page.getByRole('heading', { name: 'Друзья и общение', exact: true });
await expect(heading).toBeVisible();
expect((await heading.locator('..').boundingBox())!.height).toBeLessThanOrEqual(40);
await expect(page.getByRole('button', { name: 'Моя музыка скрыта', exact: true })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Уведомления', exact: true })).toHaveCount(0);
});
}
+3 -1
View File
@@ -65,9 +65,11 @@ for (const width of [320, 800, 1280]) {
await page.getByRole('button', { name: /moderator/ }).filter({ has: page.locator('strong') }).click(); await page.getByRole('button', { name: /moderator/ }).filter({ has: page.locator('strong') }).click();
await page.setViewportSize({ width, height: 560 }); await page.setViewportSize({ width, height: 560 });
} }
await expect.poll(async () => {
const composerBox = (await page.getByRole('textbox', { name: 'Сообщение', exact: true }).boundingBox())!; const composerBox = (await page.getByRole('textbox', { name: 'Сообщение', exact: true }).boundingBox())!;
const playerBox = (await page.getByRole('contentinfo', { name: 'Плеер' }).boundingBox())!; const playerBox = (await page.getByRole('contentinfo', { name: 'Плеер' }).boundingBox())!;
expect(composerBox.y + composerBox.height).toBeLessThanOrEqual(playerBox.y); return composerBox.y + composerBox.height - playerBox.y;
}).toBeLessThanOrEqual(0);
await expect.poll(() => page.evaluate(() => { await expect.poll(() => page.evaluate(() => {
const main = document.querySelector('main')!; const main = document.querySelector('main')!;
return Math.max(document.documentElement.scrollWidth - innerWidth, main.scrollWidth - main.clientWidth); return Math.max(document.documentElement.scrollWidth - innerWidth, main.scrollWidth - main.clientWidth);
+1 -1
View File
@@ -163,7 +163,7 @@ describe('social', () => {
await user.click(screen.getByRole('button', { name: 'Прочитано' })); await user.click(screen.getByRole('button', { name: 'Прочитано' }));
await waitFor(() => expect(screen.queryByRole('button', { name: 'Прочитано' })).not.toBeInTheDocument()); await waitFor(() => expect(screen.queryByRole('button', { name: 'Прочитано' })).not.toBeInTheDocument());
expect(api.notifications[0]?.read).toBe(true); expect(api.notifications[0]?.read).toBe(true);
await user.click(screen.getByRole('button', { name: 'Открыть' })); await user.click(screen.getByRole('button', { name: 'Открыть: bob' }));
await screen.findByRole('button', { name: 'Принять' }); await screen.findByRole('button', { name: 'Принять' });
expect(router.state.location.pathname).toBe('/friends'); expect(router.state.location.pathname).toBe('/friends');
expect(api.calls.filter(c => c.path.endsWith('/actions')).at(-1)?.body).toMatchObject({ action: 'read_notification', target_id: api.notifications[0]!.id }); expect(api.calls.filter(c => c.path.endsWith('/actions')).at(-1)?.body).toMatchObject({ action: 'read_notification', target_id: api.notifications[0]!.id });
+2 -1
View File
@@ -6,7 +6,8 @@ import { chatIcon, closeIcon, groupIcon, searchIcon } from '../../shared/ui/icon
import { useErrorText } from '../../shared/lib/errors'; import { useErrorText } from '../../shared/lib/errors';
import { ChatConversation } from './ChatConversation'; import { ChatConversation } from './ChatConversation';
import { Presence } from './Presence'; import { Presence } from './Presence';
import { useSocial, useSocialDialogOpen } from './Social'; import { useSocialDialogOpen } from './Social';
import { useSocial } from './queries';
import s from './social.module.css'; import s from './social.module.css';
/** Lives in the shell so browsing and minimizing never change the selected conversation. */ /** Lives in the shell so browsing and minimizing never change the selected conversation. */
@@ -0,0 +1,44 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useErrorText } from '../../shared/lib/errors';
import { checkIcon, chevronRightIcon, inboxIcon } from '../../shared/ui/icons';
import { Dialog, EmptyState, FormError, Icon, Skeleton } from '../../shared/ui/ui';
import { useSocial } from './queries';
import s from './notifications.module.css';
export function NotificationsDialog({ onClose, onOpen }: { onClose: () => void; onOpen: (id: string) => void }) {
const { t, i18n } = useTranslation();
const { api, inbox, key } = useSocial();
const cache = useQueryClient();
const errorText = useErrorText();
const [unreadOnly, setUnreadOnly] = useState(false);
const read = useMutation({ mutationFn: (id: string) => api.action({ action: 'read_notification', target_id: id }), onSuccess: () => cache.invalidateQueries({ queryKey: key }) });
const events = inbox.data?.notifications ?? [];
const unread = events.filter(event => !event.read).length;
const shown = unreadOnly ? events.filter(event => !event.read) : events;
return <Dialog title={t('social.notifications')} onClose={onClose} className={s.dialog}>
<nav className={s.filters} aria-label={t('social.notifications')}>
<button type="button" aria-pressed={!unreadOnly} onClick={() => setUnreadOnly(false)}>{t('social.notifications_all')}</button>
<button type="button" aria-pressed={unreadOnly} onClick={() => setUnreadOnly(true)}>{t('social.notifications_unread')}{unread > 0 && <span className={s.count}>{unread}</span>}</button>
</nav>
<FormError>{read.error || inbox.error ? errorText(read.error ?? inbox.error) : null}</FormError>
<div className={s.list} tabIndex={0} role="region" aria-label={t('social.notifications')}>
{inbox.isPending && <div className={s.loading} role="status" aria-label={t('social.loading')}>{[0, 1, 2].map(value => <Skeleton key={value} style={{ height: 68 }} />)}</div>}
{inbox.isSuccess && !shown.length && <div className={s.empty}><EmptyState icon={unreadOnly ? checkIcon : inboxIcon}>{t(unreadOnly ? 'social.notifications_caught_up' : 'social.no_notifications')}</EmptyState></div>}
{shown.map(event => {
const date = new Date(event.created_at);
return <article key={event.id} className={`${s.row} ${!event.read ? s.unread : ''}`}>
<button type="button" className={s.open} aria-label={t('social.open_notification', { name: event.actor_name })} onClick={() => onOpen(event.actor_id)}>
<span className={s.avatar} aria-hidden>{event.actor_name.slice(0, 1).toUpperCase()}</span>
<span className={s.content}><strong>{event.actor_name}</strong><span>{t(`social.kind_${event.kind}`)}</span>
<time dateTime={event.created_at} title={date.toLocaleString(i18n.language)}>{date.toLocaleString(i18n.language, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}</time>
</span>
<Icon src={chevronRightIcon} size={18} className={s.arrow} />
</button>
{!event.read && <button type="button" className={s.read} disabled={read.isPending} aria-label={t('social.mark_read')} title={t('social.mark_read')} onClick={() => read.mutate(event.id)}><span className={s.dot} /><Icon src={checkIcon} size={18} /></button>}
</article>;
})}
</div>
</Dialog>;
}
+9 -32
View File
@@ -1,10 +1,8 @@
import { socialApi, uuidv7, type ActivityVisibility, type MusicAttachment, type SocialAction, type SocialPerson } from '@pipu/core'; import { uuidv7, type ActivityVisibility, type MusicAttachment, type SocialAction, type SocialPerson } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate } from '@tanstack/react-router'; import { Link, useNavigate } from '@tanstack/react-router';
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSession, useUser } from '../../app/session';
import { useDesktop } from '../../desktop';
import { useErrorText } from '../../shared/lib/errors'; import { useErrorText } from '../../shared/lib/errors';
import { Button, Dialog, EmptyState, FormError, Icon, Menu, Skeleton } from '../../shared/ui/ui'; import { Button, Dialog, EmptyState, FormError, Icon, Menu, Skeleton } from '../../shared/ui/ui';
import { addIcon, groupIcon, inboxIcon, moreIcon, musicIcon, searchIcon, shieldIcon, visibilityOffIcon } from '../../shared/ui/icons'; import { addIcon, groupIcon, inboxIcon, moreIcon, musicIcon, searchIcon, shieldIcon, visibilityOffIcon } from '../../shared/ui/icons';
@@ -12,20 +10,8 @@ import s from './social.module.css';
import { ListeningPanel, ListenTogetherButton } from './Listening'; import { ListeningPanel, ListenTogetherButton } from './Listening';
import { ChatConversation } from './ChatConversation'; import { ChatConversation } from './ChatConversation';
import { Presence } from './Presence'; import { Presence } from './Presence';
import { useSocial } from './queries';
const scopes = new WeakMap<object, number>(); import { NotificationsDialog } from './NotificationsDialog';
let nextScope = 0;
export function useSocial() {
const session = useSession();
const user = useUser();
const server = useDesktop()?.server ?? '';
const api = useMemo(() => socialApi(session), [session]);
if (!scopes.has(session)) scopes.set(session, ++nextScope);
const key = ['social', server, scopes.get(session), user.id] as const;
const inbox = useQuery({ queryKey: key, queryFn: api.inbox, staleTime: 5000, refetchInterval: 15_000, refetchOnWindowFocus: true });
return { api, key, inbox, user };
}
export function useSocialUnread() { return useSocial().inbox.data?.unread ?? 0; } export function useSocialUnread() { return useSocial().inbox.data?.unread ?? 0; }
@@ -80,12 +66,13 @@ function ShareDialog({ value, close }: { value: ShareValue; close: () => void })
} }
export function useSocialProfileMenu() { export function useSocialProfileMenu() {
const { t, i18n } = useTranslation(); const { t } = useTranslation();
const { api, inbox, key } = useSocial(); const { api, inbox, key } = useSocial();
const cache = useQueryClient(); const cache = useQueryClient();
const navigate = useNavigate(); const navigate = useNavigate();
const errorText = useErrorText(); const errorText = useErrorText();
const { dialog, setDialog } = useContext(AccountDialogContext); const { dialog, setDialog } = useContext(AccountDialogContext);
const closeDialog = useCallback(() => setDialog(null), [setDialog]);
const action = useMutation({ mutationFn: (body: SocialAction) => api.action(body), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } }); const action = useMutation({ mutationFn: (body: SocialAction) => api.action(body), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
const visibility = useMutation({ mutationFn: (v: ActivityVisibility) => api.setVisibility(v), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } }); const visibility = useMutation({ mutationFn: (v: ActivityVisibility) => api.setVisibility(v), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
const people = inbox.data?.people ?? []; const people = inbox.data?.people ?? [];
@@ -102,23 +89,13 @@ export function useSocialProfileMenu() {
{ label: `${t('social.notifications')}${inbox.data?.unread ? ` ${inbox.data.unread}` : ''}`, icon: inboxIcon, onSelect: () => setDialog('notifications') }, { label: `${t('social.notifications')}${inbox.data?.unread ? ` ${inbox.data.unread}` : ''}`, icon: inboxIcon, onSelect: () => setDialog('notifications') },
], ],
dialogs: <> dialogs: <>
{dialog === 'privacy' && <Dialog title={t('social.privacy_settings')} onClose={() => setDialog(null)}> {dialog === 'privacy' && <Dialog title={t('social.privacy_settings')} onClose={closeDialog} className={s.preferencesDialog}>
<fieldset className={s.privacy} disabled={!inbox.data || visibility.isPending}><legend>{t('social.activity_visibility')}</legend><div className={s.actions}>{(['nobody', 'friends', 'everyone'] as const).map(v => <Button key={v} aria-pressed={currentVisibility === v} variant={currentVisibility === v ? 'primary' : 'ghost'} onClick={() => visibility.mutate(v)}>{t(`social.visibility_${v}`)}</Button>)}</div><p className={s.muted}>{t('social.activity_privacy_hint')}</p></fieldset> <fieldset className={s.privacy} disabled={!inbox.data} aria-busy={visibility.isPending}><legend>{t('social.activity_visibility')}</legend><div className={s.actions}>{(['nobody', 'friends', 'everyone'] as const).map(v => <Button key={v} aria-disabled={visibility.isPending} aria-pressed={currentVisibility === v} variant={currentVisibility === v ? 'primary' : 'ghost'} onClick={() => { if (!visibility.isPending) visibility.mutate(v); }}>{t(`social.visibility_${v}`)}</Button>)}</div><p className={s.muted}>{t('social.activity_privacy_hint')}</p></fieldset>
<FormError>{visibility.error || action.error || inbox.error ? errorText(visibility.error ?? action.error ?? inbox.error) : null}</FormError> <FormError>{visibility.error || action.error || inbox.error ? errorText(visibility.error ?? action.error ?? inbox.error) : null}</FormError>
<ListeningPanel showRequests={false} /> <ListeningPanel showRequests={false} />
{people.some(p => p.relationship === 'blocked') && <section><h3>{t('social.blocked_people')}</h3>{people.filter(p => p.relationship === 'blocked').map(p => <Person key={p.id} person={p} busy={action.isPending} open={() => {}} act={op => action.mutate({ action: op, target_id: p.id })} />)}</section>} {people.some(p => p.relationship === 'blocked') && <section><h3>{t('social.blocked_people')}</h3>{people.filter(p => p.relationship === 'blocked').map(p => <Person key={p.id} person={p} busy={action.isPending} open={() => {}} act={op => action.mutate({ action: op, target_id: p.id })} />)}</section>}
</Dialog>} </Dialog>}
{dialog === 'notifications' && <Dialog title={t('social.notifications')} onClose={() => setDialog(null)}> {dialog === 'notifications' && <NotificationsDialog onClose={closeDialog} onOpen={open} />}
<FormError>{inbox.error || action.error ? errorText(inbox.error ?? action.error) : null}</FormError>
<div className={s.events}>
{inbox.data?.notifications.length === 0 && <EmptyState icon={inboxIcon}>{t('social.no_notifications')}</EmptyState>}
{inbox.data?.notifications.map(n => <article key={n.id} className={`${s.event} ${!n.read ? s.unread : ''}`}>
<span className={s.avatar} aria-hidden>{n.actor_name.slice(0, 1).toUpperCase()}</span>
<div className={s.eventBody}><strong>{n.actor_name}</strong><p>{t(`social.kind_${n.kind}`)}</p><time className={s.muted}>{new Date(n.created_at).toLocaleString(i18n.language)}</time></div>
<div className={s.actions}><Button onClick={() => open(n.actor_id)}>{t('social.open')}</Button>{!n.read && <Button disabled={action.isPending} variant="ghost" onClick={() => action.mutate({ action: 'read_notification', target_id: n.id })}>{t('social.mark_read')}</Button>}</div>
</article>)}
</div>
</Dialog>}
</>, </>,
}; };
} }
@@ -0,0 +1,40 @@
.dialog { width: 600px; max-height: min(680px, calc(100dvh - 48px)); display: flex; flex-direction: column; overflow: hidden; background: var(--surface); }
.dialog > :first-child { flex-shrink: 0; padding: 20px 24px 16px; }
.dialog > :nth-child(2) { display: flex; flex-direction: column; gap: 0; min-height: 0; padding: 0; overflow: hidden; }
.filters { display: flex; gap: 6px; flex-shrink: 0; padding: 0 24px 16px; border-bottom: 1px solid var(--line); }
.filters button { display: flex; align-items: center; gap: 8px; min-height: 36px; padding: 0 12px; border-radius: 9px; color: var(--text2); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; }
.filters button[aria-pressed='true'] { background: var(--accent-soft); color: var(--accent2); }
.filters button:hover { background: var(--hover); color: var(--text); }
.count { font-size: 11px; font-variant-numeric: tabular-nums; color: var(--accent2); }
.list { min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: var(--line2) transparent; padding: 8px 12px 12px; }
.row { position: relative; display: flex; align-items: center; border-radius: 12px; }
.row + .row { margin-top: 4px; }
.row.unread { background: var(--accent-soft); }
.row:hover { background: var(--hover); }
.open { display: flex; align-items: center; gap: 14px; flex: 1; min-width: 0; padding: 14px 12px; border-radius: 12px; text-align: left; color: var(--text); font: inherit; cursor: pointer; }
.avatar { display: grid; place-items: center; flex-shrink: 0; width: 40px; height: 40px; border: 1px solid var(--line2); border-radius: 50%; background: var(--surface2); color: var(--accent2); font-size: 14px; font-weight: 600; }
.content { display: flex; flex-direction: column; flex: 1; min-width: 0; gap: 4px; overflow-wrap: anywhere; }
.content strong { font-size: 13px; font-weight: 600; }
.content > span { font-size: 13px; color: var(--text2); line-height: 1.45; }
.content time { color: var(--text3); font-size: 11px; line-height: 1.4; }
.arrow { flex-shrink: 0; color: var(--text3); }
.read { position: relative; display: grid; place-items: center; width: 36px; height: 36px; flex-shrink: 0; margin-right: 10px; border-radius: 9px; color: var(--accent2); cursor: pointer; }
.read > svg, .read > span:last-child { opacity: 0; }
.dot { position: absolute; width: 6px; height: 6px; border-radius: 50%; background: var(--accent2); }
.read:hover, .read:focus-visible { background: var(--accent-soft); }
.read:hover .dot, .read:focus-visible .dot { opacity: 0; }
.read:hover > :last-child, .read:focus-visible > :last-child { opacity: 1; }
.read:disabled { opacity: .5; cursor: wait; }
.open:focus-visible, .read:focus-visible, .filters button:focus-visible, .list:focus-visible { outline: 2px solid var(--accent2); outline-offset: -2px; }
.loading { display: grid; gap: 8px; padding: 12px; }
.empty { padding: 36px 20px; }
@media(max-width: 767px) {
.dialog { max-height: min(680px, calc(100dvh - 32px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px))); }
.dialog > :first-child { padding: 16px; }
.filters { padding: 0 16px 12px; }
.list { padding: 6px; }
.open { gap: 10px; padding: 12px 10px; }
.avatar { width: 34px; height: 34px; }
.read { width: 36px; min-height: 44px; margin-right: 4px; }
}
@media(hover: none) { .read > :last-child { opacity: 1; } .read .dot { display: none; } }
+20
View File
@@ -0,0 +1,20 @@
import { socialApi } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useSession, useUser } from '../../app/session';
import { useDesktop } from '../../desktop';
const scopes = new WeakMap<object, number>();
let nextScope = 0;
export function useSocial() {
const session = useSession();
const user = useUser();
const server = useDesktop()?.server ?? '';
const api = useMemo(() => socialApi(session), [session]);
if (!scopes.has(session)) scopes.set(session, ++nextScope);
const key = ['social', server, scopes.get(session), user.id] as const;
const inbox = useQuery({ queryKey: key, queryFn: api.inbox, staleTime: 5000, refetchInterval: 15_000, refetchOnWindowFocus: true });
return { api, key, inbox, user };
}
+24 -12
View File
@@ -17,7 +17,7 @@
.listSearch:focus-within { border-color: var(--accent); } .listSearch:focus-within { border-color: var(--accent); }
.listSearch input { color: var(--text); font: inherit; font-size: 13px; width: 100%; min-width: 0; background: transparent; border: 0; height: 44px; } .listSearch input { color: var(--text); font: inherit; font-size: 13px; width: 100%; min-width: 0; background: transparent; border: 0; height: 44px; }
.listSearch input::placeholder { color: var(--text3); } .listSearch input::placeholder { color: var(--text3); }
.listSearch input:focus-visible { outline-offset: 4px; } .listSearch input:focus-visible { outline: none; }
.dialogRow { display: flex; align-items: center; gap: 12px; width: 100%; padding: 14px 12px; text-align: left; border-radius: 12px; margin-bottom: 4px; transition: background .18s, transform .18s; } .dialogRow { display: flex; align-items: center; gap: 12px; width: 100%; padding: 14px 12px; text-align: left; border-radius: 12px; margin-bottom: 4px; transition: background .18s, transform .18s; }
.dialogRow:hover { background: var(--hover); } .dialogRow:hover { background: var(--hover); }
.dialogRow:active { transform: scale(.985); } .dialogRow:active { transform: scale(.985); }
@@ -33,7 +33,7 @@
.presence { display: inline-flex; align-items: center; gap: 5px; color: var(--text2); font-size: 11px; white-space: nowrap; } .presence { display: inline-flex; align-items: center; gap: 5px; color: var(--text2); font-size: 11px; white-space: nowrap; }
.presenceDot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--text3); flex-shrink: 0; transition: background .2s; } .presenceDot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--text3); flex-shrink: 0; transition: background .2s; }
.presenceOnline .presenceDot { background: var(--ok); } .presenceOnline .presenceDot { background: var(--ok); }
.directory, .events { flex: 1; min-height: 0; overflow-y: auto; padding: 16px 24px; animation: panelIn .22s ease-out; } .directory { flex: 1; min-height: 0; overflow-y: auto; padding: 16px 24px; animation: panelIn .22s ease-out; }
.directory .peopleList { padding: 0; overflow: visible; } .directory .peopleList { padding: 0; overflow: visible; }
.directory .listSearch { max-width: 440px; margin-left: 0; } .directory .listSearch { max-width: 440px; margin-left: 0; }
.person { display: flex; flex-wrap: wrap; align-items: center; gap: 20px; padding: 20px 4px; border-bottom: 1px solid var(--line); min-width: 0; overflow-wrap: anywhere; } .person { display: flex; flex-wrap: wrap; align-items: center; gap: 20px; padding: 20px 4px; border-bottom: 1px solid var(--line); min-width: 0; overflow-wrap: anywhere; }
@@ -53,17 +53,29 @@
.welcomeIcon { color: var(--accent2); background: var(--accent-soft); display: grid; place-items: center; width: 80px; height: 80px; border-radius: 24px; margin-bottom: 8px; } .welcomeIcon { color: var(--accent2); background: var(--accent-soft); display: grid; place-items: center; width: 80px; height: 80px; border-radius: 24px; margin-bottom: 8px; }
.welcome h2 { font-size: 22px; letter-spacing: -.025em; margin: 0; } .welcome h2 { font-size: 22px; letter-spacing: -.025em; margin: 0; }
.welcome p { color: var(--text2); max-width: 280px; margin: 0 0 8px; line-height: 1.7; } .welcome p { color: var(--text2); max-width: 280px; margin: 0 0 8px; line-height: 1.7; }
.events h2 { font-size: 18px; margin: 8px 0 20px; }
.event { display: flex; flex-wrap: wrap; align-items: center; gap: 16px; padding: 18px 12px; border-bottom: 1px solid var(--line); border-radius: 10px; }
.eventBody { flex: 1; min-width: 100px; overflow-wrap: anywhere; }
.eventBody p { color: var(--text2); font-size: 13px; margin: 4px 0; }
.event.unread { background: var(--accent-soft); }
.privacy { border: 1px solid var(--line); border-radius: 12px; padding: 16px; margin: 0 0 16px; min-width: 0; } .privacy { border: 1px solid var(--line); border-radius: 12px; padding: 16px; margin: 0 0 16px; min-width: 0; }
.privacy legend { color: var(--text2); padding: 0 8px; font-size: 13px; } .privacy legend { color: var(--text2); padding: 0 8px; font-size: 13px; }
.privacy summary { cursor: pointer; font-weight: 600; } .privacy summary { cursor: pointer; font-weight: 600; }
.privacy p { margin-bottom: 0; } .privacy p { margin-bottom: 0; }
.privacy .privacy { margin: 12px 0; } .privacy .privacy { margin: 12px 0; }
.choice { display: flex; gap: 10px; padding: 12px 0; align-items: center; } .privacy input { accent-color: var(--accent); }
.preferencesDialog { width: 560px; max-height: min(720px, calc(100dvh - 48px)); display: flex; flex-direction: column; overflow: hidden; background: var(--surface); }
.preferencesDialog > :first-child { padding: 20px 24px 16px; flex-shrink: 0; border-bottom: 1px solid var(--line); }
.preferencesDialog > :nth-child(2) { min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; }
.preferencesDialog fieldset.privacy { border: 0; padding: 0; margin: 0; }
.preferencesDialog .privacy legend { padding: 0; margin-bottom: 12px; color: var(--text); font-weight: 600; }
.preferencesDialog .privacy .actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; padding: 4px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface2); }
.preferencesDialog .privacy .actions button { min-width: 0; padding: 0 8px; border-radius: 8px; min-height: 36px; }
.preferencesDialog details.privacy { padding: 16px; margin: 8px 0 0; border: 1px solid var(--line); background: var(--surface2); }
.preferencesDialog details.privacy > summary { line-height: 1.5; }
.preferencesDialog details.privacy fieldset { margin: 20px 0; }
.preferencesDialog details.privacy > label { display: flex; align-items: flex-start; gap: 10px; font-size: 13px; line-height: 1.5; }
.preferencesDialog details.privacy > label input { margin-top: 3px; }
.choice { display: flex; gap: 12px; padding: 14px; align-items: center; border: 1px solid var(--line); border-radius: 12px; cursor: pointer; background: var(--surface2); font-size: 13px; line-height: 1.5; }
.choice + .choice { margin-top: 8px; }
.choice:has(input:checked) { border-color: var(--accent); background: var(--accent-soft); }
.choice:has(input:disabled) { opacity: .5; cursor: not-allowed; }
.choice input { flex-shrink: 0; accent-color: var(--accent); }
.stream { display: flex; gap: 16px; flex-wrap: wrap; align-items: center; justify-content: space-between; padding: 16px; border: 1px solid var(--line); border-left: 3px solid var(--ok); border-radius: 14px; background: var(--surface); margin-bottom: 20px; } .stream { display: flex; gap: 16px; flex-wrap: wrap; align-items: center; justify-content: space-between; padding: 16px; border: 1px solid var(--line); border-left: 3px solid var(--ok); border-radius: 14px; background: var(--surface); margin-bottom: 20px; }
.chat { position: relative; display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; background: var(--surface); animation: conversationIn .24s cubic-bezier(.2,.8,.2,1); } .chat { position: relative; display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; background: var(--surface); animation: conversationIn .24s cubic-bezier(.2,.8,.2,1); }
.chatHeader { display: flex; align-items: center; gap: 12px; padding: 16px 22px; border-bottom: 1px solid var(--line); flex-shrink: 0; } .chatHeader { display: flex; align-items: center; gap: 12px; padding: 16px 22px; border-bottom: 1px solid var(--line); flex-shrink: 0; }
@@ -94,7 +106,7 @@
.messageText { white-space: pre-wrap; user-select: text; line-height: 1.65; font-size: 14px; min-width: 0; } .messageText { white-space: pre-wrap; user-select: text; line-height: 1.65; font-size: 14px; min-width: 0; }
.messageText a, .attachmentCaption a { color: var(--accent2); } .messageText a, .attachmentCaption a { color: var(--accent2); }
.messageFoot { display: flex; align-items: center; justify-content: end; gap: 10px; min-height: 30px; } .messageFoot { display: flex; align-items: center; justify-content: end; gap: 10px; min-height: 30px; }
.messageFoot small { font-size: 10px; color: var(--text3); } .messageFoot small { font-size: 11px; color: var(--text2); }
.messageFoot button { width: 28px; height: 28px; min-width: 28px; padding: 0; border-radius: 8px; color: var(--text3); } .messageFoot button { width: 28px; height: 28px; min-width: 28px; padding: 0; border-radius: 8px; color: var(--text3); }
.messageFoot button:hover { color: var(--text); background: var(--hover); } .messageFoot button:hover { color: var(--text); background: var(--hover); }
.quote { margin: 0 0 2px; padding: 6px 10px; border-left: 2px solid var(--accent2); border-radius: 0 6px 6px 0; background: var(--hover); color: var(--text2); font-size: 12px; max-height: 72px; overflow: hidden; } .quote { margin: 0 0 2px; padding: 6px 10px; border-left: 2px solid var(--accent2); border-radius: 0 6px 6px 0; background: var(--hover); color: var(--text2); font-size: 12px; max-height: 72px; overflow: hidden; }
@@ -131,7 +143,7 @@
.musicMessages .mediaBubble { align-self: stretch; max-width: 100%; background: transparent; border: 1px solid var(--line); border-radius: 12px; } .musicMessages .mediaBubble { align-self: stretch; max-width: 100%; background: transparent; border: 1px solid var(--line); border-radius: 12px; }
.musicMessages .musicCard { width: 100%; } .musicMessages .musicCard { width: 100%; }
.miniDock { grid-area: content; align-self: end; justify-self: end; z-index: 25 !important; display: flex; flex-direction: column; align-items: end; gap: 12px; width: min(600px, calc(100% - 32px)); min-height: 0; margin: 16px; pointer-events: none; } .miniDock { grid-area: content; align-self: end; justify-self: end; z-index: 25 !important; display: flex; flex-direction: column; align-items: end; gap: 12px; width: min(600px, calc(100% - 32px)); min-height: 0; margin: 16px; pointer-events: none; }
.miniPanel { pointer-events: auto; width: 100%; height: min(640px, calc(100dvh - 250px)); min-height: 0; display: flex; flex-direction: column; border: 1px solid var(--line2); border-radius: 18px; background: var(--surface); box-shadow: 0 16px 64px #0008; overflow: hidden; animation: panelIn .2s ease-out; } .miniPanel { pointer-events: auto; width: 100%; height: min(640px, calc(100dvh - 250px)); min-height: 0; display: flex; flex-direction: column; border: 1px solid var(--line2); border-radius: 18px; background: var(--surface); box-shadow: var(--shadow); overflow: hidden; animation: panelIn .2s ease-out; }
.miniDockIsland { margin-bottom: 132px; } .miniDockIsland { margin-bottom: 132px; }
.miniDockIsland .miniPanel { height: min(640px, calc(100dvh - 290px)); } .miniDockIsland .miniPanel { height: min(640px, calc(100dvh - 290px)); }
.miniPanel[hidden] { display: none; } .miniPanel[hidden] { display: none; }
@@ -186,9 +198,9 @@
.chatTabs { padding: 0 14px; gap: 18px; } .chatTabs button { min-height: 44px; font-size: 11px; } .chatTabs { padding: 0 14px; gap: 18px; } .chatTabs button { min-height: 44px; font-size: 11px; }
.nowListening { padding: 8px 14px; } .messages { padding: 12px; gap: 10px; } .bubble { max-width: 92%; font-size: 14px; padding: 9px 12px 4px; } .nowListening { padding: 8px 14px; } .messages { padding: 12px; gap: 10px; } .bubble { max-width: 92%; font-size: 14px; padding: 9px 12px 4px; }
.composer { gap: 4px; padding: 10px 8px; } .composer textarea { font-size: 16px; padding: 10px; } .composer .iconButton { min-width: 40px; width: 40px; height: 44px; } .composer { gap: 4px; padding: 10px 8px; } .composer textarea { font-size: 16px; padding: 10px; } .composer .iconButton { min-width: 40px; width: 40px; height: 44px; }
.directory, .events { padding: 12px; } .person { gap: 12px; padding: 16px 0; } .person > .actions { margin-left: 0; width: 100%; } .person .activity { width: 100%; padding-left: 54px; } .directory { padding: 12px; } .person { gap: 12px; padding: 16px 0; } .person > .actions { margin-left: 0; width: 100%; } .person .activity { width: 100%; padding-left: 54px; }
.musicCard { gap: 8px; } .musicCard > :first-child { width: 40px !important; height: 40px !important; } .musicCard .iconButton { min-width: 32px; width: 32px; } .musicInfo a { font-size: 13px; } .musicCard { gap: 8px; } .musicCard > :first-child { width: 40px !important; height: 40px !important; } .musicCard .iconButton { min-width: 32px; width: 32px; } .musicInfo a { font-size: 13px; }
.chatSearch { padding: 10px 12px; gap: 6px; } .replyBar { padding: 8px 12px; } .privacy { padding: 12px; } .privacy .actions { gap: 4px; } .privacy .actions button { padding: 0 12px; } .chatSearch { padding: 10px 12px; gap: 6px; } .replyBar { padding: 8px 12px; } .privacy { padding: 12px; } .privacy .actions { gap: 4px; } .privacy .actions button { padding: 0 12px; }
} }
@media(pointer: coarse) { .chatTools button, .messageFoot button, .reactions button { min-height: 44px; } } @media(pointer: coarse) { .chatTools button, .messageFoot button, .reactions button { min-height: 44px; } }
@media(prefers-reduced-motion: reduce) { .chat, .bubble, .directory, .events, .chatSearch, .replyBar, .emojiPicker, .jumpToBottom, .typingDots i, .tabs > button::after, .chatTabs button::after { animation: none; } .dialogRow, .tabs > button, .chatTabs button, .composer textarea, .reactions button, .emojiPicker button { transition: none; } .dialogRow:active, .reactions button:hover, .emojiPicker button:hover { transform: none; } } @media(prefers-reduced-motion: reduce) { .chat, .bubble, .directory, .chatSearch, .replyBar, .emojiPicker, .jumpToBottom, .typingDots i, .tabs > button::after, .chatTabs button::after { animation: none; } .dialogRow, .tabs > button, .chatTabs button, .composer textarea, .reactions button, .emojiPicker button { transition: none; } .dialogRow:active, .reactions button:hover, .emojiPicker button:hover { transform: none; } }
+102
View File
@@ -0,0 +1,102 @@
import type { AuthSession, ListeningState, Track } from '@pipu/core';
import { refreshListening } from '@pipu/core';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { Player } from './player';
const track: Track = {
id: 'local', title: 'Local', duration_ms: 200_000, artists: [],
album: { id: 'album', title: 'Album', year: null, cover_url: null, cover_colors: [] },
genres: [], disc_no: null, track_no: null, available: true, stream_url: '/s/local', reaction: null, features: null,
};
const remote = { ...track, id: 'remote', stream_url: '/s/remote' };
const ok = (data: unknown) => ({ data, response: new Response() });
const players: Player[] = [];
function fixture() {
let snapshot: ListeningState = { room: null, requests: [], preferences: { requests: 'friends', auto_accept: 'friends', autoplay: true }, server_time: new Date().toISOString() };
const session = {
state: { status: 'signed_in', user: { id: 'guest', ui_prefs: { crossfade_s: 6, normalize: false } } },
client: {
GET: vi.fn(async (path: string) => ok(path === '/api/v1/me/social/listening' ? snapshot : remote)),
PUT: vi.fn(async () => ok({})), POST: vi.fn(async () => ok({})),
},
} as unknown as AuthSession;
const audio = document.createElement('audio');
const overlap = document.createElement('audio');
vi.spyOn(globalThis, 'Audio').mockImplementation(function () { return overlap; });
const frames: FrameRequestCallback[] = [];
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => { frames.push(callback); return frames.length; });
const clock = vi.spyOn(performance, 'now').mockReturnValue(0);
const player = new Player(session, audio);
players.push(player);
const startFade = () => {
player.playList([track, { ...track, id: 'next', stream_url: '/s/next' }], 0, { type: 'queue' });
Object.defineProperty(audio, 'duration', { configurable: true, value: 200 });
audio.currentTime = 196;
audio.dispatchEvent(new Event('timeupdate'));
clock.mockReturnValue(3000);
frames.shift()!(3000);
expect(overlap.paused).toBe(false);
expect(overlap.volume).toBeGreaterThan(0);
};
const follow = async () => {
snapshot = { ...snapshot, room: { id: 'room', owner_id: 'host', owner_name: 'Host', device_id: 'host-device', track_id: remote.id, position_ms: 1000, playing: true, updated_at: snapshot.server_time, participants: [] } };
player.start();
await vi.waitFor(() => expect(player.getState()).toMatchObject({ track: { id: 'remote' }, playing: true, listening: { locked: true } }));
};
return { player, audio, overlap, frames, clock, startFade, follow, session, setRoom: (room: ListeningState['room']) => { snapshot = { ...snapshot, room }; } };
}
afterEach(() => { players.splice(0).forEach(player => player.dispose()); vi.restoreAllMocks(); localStorage.removeItem('pipu.player'); });
describe('shared listening audio ownership', () => {
it('stops a local crossfade before following and ignores its queued animation', async () => {
const e = fixture();
e.startFade();
const staleFrame = e.frames.shift()!;
await e.follow();
expect(e.audio.paused).toBe(false);
expect(e.overlap.paused).toBe(true);
expect(e.overlap.getAttribute('src')).toBeNull();
staleFrame(6000);
expect(e.player.getState().track?.id).toBe('remote');
expect(e.overlap.paused).toBe(true);
e.setRoom(null);
refreshListening(e.session);
await vi.waitFor(() => expect(e.player.getState().listening?.room).toBeNull());
expect(e.audio.paused).toBe(true);
expect(e.overlap.paused).toBe(true);
});
it('silences the overlapping element on pause', () => {
const e = fixture();
e.startFade();
e.player.pause();
expect(e.audio.paused).toBe(true);
expect(e.overlap.paused).toBe(true);
});
it('cancels an old transition when another track is selected', () => {
const e = fixture();
e.startFade();
const staleFrame = e.frames.shift()!;
e.player.playList([remote], 0, { type: 'queue' });
e.clock.mockReturnValue(6000);
staleFrame(6000);
expect(e.audio.getAttribute('src')).toBe('/s/remote');
expect(e.player.getState().track?.id).toBe('remote');
expect(e.overlap.paused).toBe(true);
});
it('still completes an uninterrupted local crossfade with one active source', async () => {
const e = fixture();
e.startFade();
e.clock.mockReturnValue(6000);
e.frames.shift()!(6000);
await Promise.resolve();
expect(e.player.getState().track?.id).toBe('next');
expect(e.audio.getAttribute('src')).toBe('/s/next');
expect(e.audio.paused).toBe(false);
expect(e.overlap.paused).toBe(true);
});
});
+29 -7
View File
@@ -49,6 +49,7 @@ export class Player {
private get audio() { return this.engine.audio; } private get audio() { return this.engine.audio; }
private get overlap() { return this.engine.overlap; } private get overlap() { return this.engine.overlap; }
private fading = false; private fading = false;
private fadeVersion = 0;
private ignoreEnded = false; private ignoreEnded = false;
private readonly queue = new PlayQueue(); private readonly queue = new PlayQueue();
private readonly events: EventBuffer; private readonly events: EventBuffer;
@@ -106,7 +107,7 @@ export class Player {
if (!this.listening) { if (!this.listening) {
this.listening = new ListeningCoordinator(this.session, this.activity.deviceId, { this.listening = new ListeningCoordinator(this.session, this.activity.deviceId, {
read: this.getState, read: this.getState,
pause: () => { this.restoreVersion++; this.leaveWave(); this.audio.pause(); }, pause: () => { this.restoreVersion++; this.leaveWave(); this.cancelCrossfade(); this.audio.pause(); },
apply: (room, position, current) => this.syncListening(room, position, current), apply: (room, position, current) => this.syncListening(room, position, current),
changed: listening => this.update({ listening }), changed: listening => this.update({ listening }),
}); });
@@ -291,6 +292,7 @@ export class Player {
pause() { pause() {
if (this.listening?.locked && !this.applyingSync) return; if (this.listening?.locked && !this.applyingSync) return;
this.cancelCrossfade();
this.audio.pause(); this.audio.pause();
} }
@@ -332,6 +334,7 @@ export class Player {
const track = this.queue.current; const track = this.queue.current;
if (!track) return; if (!track) return;
const to = Math.min(Math.max(ms, 0), track.duration_ms); const to = Math.min(Math.max(ms, 0), track.duration_ms);
this.cancelCrossfade();
const from = Math.round(this.audio.currentTime * 1000); const from = Math.round(this.audio.currentTime * 1000);
this.attempt?.seek(to); this.attempt?.seek(to);
this.audio.currentTime = to / 1000; this.audio.currentTime = to / 1000;
@@ -398,6 +401,7 @@ export class Player {
// --- загрузка трека --- // --- загрузка трека ---
private async load(autoplay: boolean, startMs = 0, current = () => true) { private async load(autoplay: boolean, startMs = 0, current = () => true) {
this.cancelCrossfade();
const track = this.queue.current; const track = this.queue.current;
if (!track?.stream_url) return; if (!track?.stream_url) return;
const version = this.restoreVersion; const version = this.restoreVersion;
@@ -484,10 +488,25 @@ export class Player {
this.engine.applyLoudness(user * normalizeGain(this.state.track?.features?.loudness_lufs, this.normalizeOn()) * mix); this.engine.applyLoudness(user * normalizeGain(this.state.track?.features?.loudness_lufs, this.normalizeOn()) * mix);
} }
/** A cancelled transition must not keep a second source audible or change the new queue. */
private cancelCrossfade() {
this.fadeVersion++;
const wasFading = this.fading;
this.fading = false;
this.ignoreEnded = false;
this.overlap.pause();
this.overlap.volume = 0;
if (this.overlap.hasAttribute('src')) {
this.overlap.removeAttribute('src');
this.overlap.load();
}
if (wasFading) this.applyLoudness();
}
/** В конце трека накладывает следующий на crossfade_s секунд. */ /** В конце трека накладывает следующий на crossfade_s секунд. */
private maybeCrossfade(positionMs: number) { private maybeCrossfade(positionMs: number) {
const cf = this.crossfadeSeconds(); const cf = this.crossfadeSeconds();
if (this.fading || cf <= 0 || this.state.repeat === 'one') return; if (this.listening?.locked || this.fading || cf <= 0 || this.state.repeat === 'one') return;
const dur = this.audio.duration; const dur = this.audio.duration;
if (!Number.isFinite(dur)) return; if (!Number.isFinite(dur)) return;
const left = dur * 1000 - positionMs; const left = dur * 1000 - positionMs;
@@ -495,15 +514,16 @@ export class Player {
const next = this.state.queue[this.state.index + 1]; const next = this.state.queue[this.state.index + 1];
if (!next?.stream_url || !next.available) return; if (!next?.stream_url || !next.available) return;
this.fading = true; this.fading = true;
const version = ++this.fadeVersion;
this.offline?.keep([this.queue.current!.id, next.id]); this.offline?.keep([this.queue.current!.id, next.id]);
this.overlap.src = this.offline?.local(next).stream_url ?? next.stream_url; this.overlap.src = this.offline?.local(next).stream_url ?? next.stream_url;
this.overlap.volume = 0; this.overlap.volume = 0;
void this.overlap.play().catch(() => { void this.overlap.play().catch(() => {
this.fading = false; if (version === this.fadeVersion) this.cancelCrossfade();
}); });
const started = performance.now(); const started = performance.now();
const step = () => { const step = () => {
if (!this.fading) return; if (!this.fading || version !== this.fadeVersion) return;
const p = Math.min(1, (performance.now() - started) / (cf * 1000)); const p = Math.min(1, (performance.now() - started) / (cf * 1000));
this.applyLoudness(1 - p); this.applyLoudness(1 - p);
this.overlap.volume = Math.min(1, (this.state.muted ? 0 : this.state.volume) * normalizeGain(next.features?.loudness_lufs, this.normalizeOn()) * p); this.overlap.volume = Math.min(1, (this.state.muted ? 0 : this.state.volume) * normalizeGain(next.features?.loudness_lufs, this.normalizeOn()) * p);
@@ -511,12 +531,12 @@ export class Player {
requestAnimationFrame(step); requestAnimationFrame(step);
return; return;
} }
this.finishCrossfade(next); this.finishCrossfade(next, version);
}; };
requestAnimationFrame(step); requestAnimationFrame(step);
} }
private finishCrossfade(next: Track) { private finishCrossfade(next: Track, version: number) {
const at = this.overlap.currentTime; const at = this.overlap.currentTime;
this.ignoreEnded = true; this.ignoreEnded = true;
this.audio.pause(); this.audio.pause();
@@ -545,6 +565,7 @@ export class Player {
this.media.updateMediaMetadata(next); this.media.updateMediaMetadata(next);
void this.offline?.save(next).catch(() => undefined); void this.offline?.save(next).catch(() => undefined);
void this.audio.play().finally(() => { void this.audio.play().finally(() => {
if (version !== this.fadeVersion) return;
this.fading = false; this.fading = false;
this.ignoreEnded = false; this.ignoreEnded = false;
}); });
@@ -690,6 +711,7 @@ export class Player {
this.activity = null; this.activity = null;
this.restoreVersion++; this.restoreVersion++;
this.wavePolicy.activate(null); this.wavePolicy.activate(null);
this.fadeVersion++;
this.fading = false; this.fading = false;
this.finishAttempt('skip'); this.finishAttempt('skip');
this.engine.dispose(); this.engine.dispose();
@@ -708,7 +730,7 @@ export class Player {
this.listening?.beginAudio(); this.listening?.beginAudio();
} }
private async syncListening(room: ListeningRoom, position: number, current: () => boolean) { private async syncListening(room: ListeningRoom, position: number, current: () => boolean) {
if (!room.track_id) { this.audio.pause(); return; } if (!room.track_id) { this.cancelCrossfade(); this.audio.pause(); return; }
if (this.remoteRoom !== room.id || this.state.track?.id !== room.track_id || this.state.queue.length !== 1 || this.state.wave) { if (this.remoteRoom !== room.id || this.state.track?.id !== room.track_id || this.state.queue.length !== 1 || this.state.wave) {
const track = unwrap(await this.session.client.GET('/api/v1/tracks/{id}', { params: { path: { id: room.track_id } } })); const track = unwrap(await this.session.client.GET('/api/v1/tracks/{id}', { params: { path: { id: room.track_id } } }));
if (!current()) return; if (!current()) return;
+7 -3
View File
@@ -149,21 +149,25 @@ export function Dialog({
onClose, onClose,
children, children,
footer, footer,
className,
}: { }: {
title: string; title: string;
onClose: () => void; onClose: () => void;
children: ReactNode; children: ReactNode;
footer?: ReactNode; footer?: ReactNode;
className?: string;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const titleId = useId(); const titleId = useId();
const closeRef = useRef(onClose);
closeRef.current = onClose;
useEffect(() => { useEffect(() => {
const prev = document.activeElement as HTMLElement | null; const prev = document.activeElement as HTMLElement | null;
const first = ref.current?.querySelector<HTMLElement>('input, select, button:not([data-close])'); const first = ref.current?.querySelector<HTMLElement>('input, select, button:not([data-close])');
(first ?? ref.current)?.focus(); (first ?? ref.current)?.focus();
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose(); if (e.key === 'Escape') closeRef.current();
if (e.key === 'Tab') { if (e.key === 'Tab') {
const controls = [...(ref.current?.querySelectorAll<HTMLElement>( const controls = [...(ref.current?.querySelectorAll<HTMLElement>(
'a[href], button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex="0"]', 'a[href], button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex="0"]',
@@ -182,10 +186,10 @@ export function Dialog({
document.removeEventListener('keydown', onKey); document.removeEventListener('keydown', onKey);
prev?.focus(); prev?.focus();
}; };
}, [onClose]); }, []);
return createPortal( return createPortal(
<div className={s.overlay} onMouseDown={(e) => e.target === e.currentTarget && onClose()}> <div className={s.overlay} onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
<div ref={ref} className={s.dialog} role="dialog" aria-modal="true" aria-labelledby={titleId} tabIndex={-1}> <div ref={ref} className={cx(s.dialog, className)} role="dialog" aria-modal="true" aria-labelledby={titleId} tabIndex={-1}>
<div className={s.dialogHead}> <div className={s.dialogHead}>
<h2 id={titleId}>{title}</h2> <h2 id={titleId}>{title}</h2>
<button type="button" data-close className={s.iconButton} onClick={onClose} aria-label={t('form.close')}> <button type="button" data-close className={s.iconButton} onClick={onClose} aria-label={t('form.close')}>
+8
View File
@@ -14,6 +14,14 @@ export function installE2EFetch() {
const alice = [...api.users.values()].find(u => u.username === 'alice')!; const alice = [...api.users.values()].find(u => u.username === 'alice')!;
const friend = [...api.users.values()].find(u => u.username === 'moderator')!; const friend = [...api.users.values()].find(u => u.username === 'moderator')!;
api.friendships.set([alice.id, friend.id].sort().join(':'), { sender: friend.id, accepted: true }); api.friendships.set([alice.id, friend.id].sort().join(':'), { sender: friend.id, accepted: true });
if (localStorage.getItem('pipu.e2e.notifications') === '1') {
const kinds = ['message', 'friend_request', 'friend_accepted', 'music'] as const;
for (let index = 0; index < 28; index++) api.notifications.push({
id: `event-${index}`, recipient: alice.id, actor_id: friend.id,
actor_name: index % 3 === 0 ? 'JoyRacoon' : index % 3 === 1 ? 'Александра' : 'Константин',
kind: kinds[index % kinds.length]!, created_at: new Date(Date.now() - index * 36e5).toISOString(), read: index >= 4,
});
}
const orig = window.fetch.bind(window); const orig = window.fetch.bind(window);
window.fetch = async (input, init) => { window.fetch = async (input, init) => {
const req = new Request(input, init); const req = new Request(input, init);
+4
View File
@@ -387,6 +387,10 @@
"no_results": "No playlists found" "no_results": "No playlists found"
}, },
"social": { "social": {
"notifications_all": "All",
"notifications_unread": "Unread",
"notifications_caught_up": "You’re all caught up",
"open_notification": "Open: {{name}}",
"mini_chat": "Friends and chat", "mini_chat": "Friends and chat",
"open_full": "Open full view", "open_full": "Open full view",
"minimize_chat": "Minimize chat", "minimize_chat": "Minimize chat",
+4
View File
@@ -387,6 +387,10 @@
"no_results": "Плейлисты не найдены" "no_results": "Плейлисты не найдены"
}, },
"social": { "social": {
"notifications_all": "Все",
"notifications_unread": "Непрочитанные",
"notifications_caught_up": "Всё прочитано",
"open_notification": "Открыть: {{name}}",
"mini_chat": "Друзья и чат", "mini_chat": "Друзья и чат",
"open_full": "Открыть раздел", "open_full": "Открыть раздел",
"minimize_chat": "Свернуть чат", "minimize_chat": "Свернуть чат",