feat(web): align the web UI with the design prototype
ci / api / api (push) Successful in 43s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 52s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m36s
ci / ts / ts (packages + web) (push) Successful in 1m39s

- Ambient background in the app shell: three drifting blurred blobs,
  the third one tinted with the current track's cover; can be turned off
  in settings and stays still with reduced motion.
- Island player variant (floating glass panel) next to the bar, chosen
  in settings; player source line ("Album · …", "My Wave"), reactions
  around the transport controls, queue drawer.
- Sidebar: like / dislike counters, pending uploads badge on Queue,
  admin Settings page holding the upload queue limits.
- Collection: header tile with kicker, underline tabs, time / number
  lead column with an equalizer for the current track, album column,
  dimmed dislikes with undo, Play all / Shuffle pills.
- Album: cover-tinted header and Play / Shuffle; artist: artist wave,
  Play and popular tracks (new GET /artists/{id}/top-tracks).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 18:13:24 +03:00
1 parent 3afed02f23
commit 1c4e209e86
29 files changed
+1369 -327

No files matched your search

+24
View File
@@ -894,6 +894,30 @@ paths:
$ref: "#/components/schemas/AlbumSummaryList" $ref: "#/components/schemas/AlbumSummaryList"
default: default:
$ref: "#/components/responses/Problem" $ref: "#/components/responses/Problem"
/api/v1/artists/{id}/top-tracks:
parameters:
- $ref: "#/components/parameters/EntityId"
get:
operationId: listArtistTopTracks
tags: [catalog]
summary: Популярные треки исполнителя (по прослушиваниям всех пользователей)
parameters:
- name: limit
in: query
schema:
type: integer
minimum: 1
maximum: 50
default: 10
responses:
"200":
description: Треки, самые слушаемые первыми
content:
application/json:
schema:
$ref: "#/components/schemas/TrackList"
default:
$ref: "#/components/responses/Problem"
/api/v1/albums/{id}: /api/v1/albums/{id}:
parameters: parameters:
- $ref: "#/components/parameters/EntityId" - $ref: "#/components/parameters/EntityId"
+9
View File
@@ -11,6 +11,7 @@ import {
import { AuditPage } from '../pages/admin/AuditPage'; import { AuditPage } from '../pages/admin/AuditPage';
import { LibraryPage } from '../pages/admin/LibraryPage'; import { LibraryPage } from '../pages/admin/LibraryPage';
import { PatternsPage } from '../pages/admin/PatternsPage'; import { PatternsPage } from '../pages/admin/PatternsPage';
import { AdminSettingsPage } from '../pages/admin/AdminSettingsPage';
import { UploadQueuePage } from '../pages/admin/UploadQueuePage'; import { UploadQueuePage } from '../pages/admin/UploadQueuePage';
import { MyUploadsPage } from '../pages/uploads/MyUploadsPage'; import { MyUploadsPage } from '../pages/uploads/MyUploadsPage';
import { SuggestPage } from '../pages/uploads/SuggestPage'; import { SuggestPage } from '../pages/uploads/SuggestPage';
@@ -224,6 +225,13 @@ const uploadQueueRoute = createRoute({
component: UploadQueuePage, component: UploadQueuePage,
}); });
const adminSettingsRoute = createRoute({
getParentRoute: () => appRoute,
path: '/admin/settings',
beforeLoad: adminOnly,
component: AdminSettingsPage,
});
const routeTree = rootRoute.addChildren([ const routeTree = rootRoute.addChildren([
loginRoute, loginRoute,
setupAdminRoute, setupAdminRoute,
@@ -245,6 +253,7 @@ const routeTree = rootRoute.addChildren([
suggestRoute, suggestRoute,
myUploadsRoute, myUploadsRoute,
uploadQueueRoute, uploadQueueRoute,
adminSettingsRoute,
]), ]),
]); ]);
+50
View File
@@ -110,6 +110,56 @@
grid-template-columns: 40px minmax(0, 2fr) minmax(0, 1fr) auto; grid-template-columns: 40px minmax(0, 2fr) minmax(0, 1fr) auto;
} }
.track:has(.lead) {
grid-template-columns: 44px 44px minmax(0, 1fr) auto;
}
.track:has(.lead):has(.trackAlbum) {
grid-template-columns: 44px 44px minmax(0, 1.4fr) minmax(0, 1fr) auto;
}
.lead {
display: flex;
justify-content: center;
font-size: 13px;
color: var(--text3);
font-variant-numeric: tabular-nums;
}
.dimmed {
opacity: 0.6;
}
.eq {
display: flex;
gap: 2px;
align-items: flex-end;
height: 14px;
}
.eq span {
width: 3px;
height: 14px;
border-radius: 2px;
background: var(--accent2);
transform-origin: bottom;
animation: pipuEq 0.55s ease-in-out infinite alternate;
}
.eq span:nth-child(2) {
animation-duration: 0.72s;
animation-delay: 0.1s;
}
.eq span:nth-child(3) {
animation-duration: 0.89s;
animation-delay: 0.2s;
}
.eqPaused span {
animation-play-state: paused;
}
.track:hover { .track:hover {
background: var(--hover); background: var(--hover);
} }
+35 -6
View File
@@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next';
import { formatDuration } from '../shared/lib/format'; import { formatDuration } from '../shared/lib/format';
import { usePlayer, usePlayerState } from '../player/context'; import { usePlayer, usePlayerState } from '../player/context';
import type { PlayContext } from '../player/player'; import type { PlayContext } from '../player/player';
import { albumIcon, likeFillIcon, likeIcon, personIcon, playIcon } from '../shared/ui/icons'; import { albumIcon, likeFillIcon, likeIcon, personIcon, playIcon, undoIcon} from '../shared/ui/icons';
import { cx, Icon } from '../shared/ui/ui'; import { cx, Icon } from '../shared/ui/ui';
import s from './catalog.module.css'; import s from './catalog.module.css';
@@ -112,17 +112,23 @@ export function TrackList({
numbered, numbered,
context, context,
rowMeta, rowMeta,
lead,
disliked,
}: { }: {
tracks: Track[]; tracks: Track[];
showAlbum?: boolean; showAlbum?: boolean;
numbered?: boolean; numbered?: boolean;
context: PlayContext; context: PlayContext;
/** Дополнительный текст под исполнителями (например, время прослушивания в истории). */ /** Дополнительный текст под исполнителями. */
rowMeta?: (t: Track, i: number) => string | undefined; rowMeta?: (t: Track, i: number) => string | undefined;
/** Первая колонка перед обложкой (docs/design: время в истории, номер в лайках); у текущего трека — эквалайзер. */
lead?: (t: Track, i: number) => string;
/** Список дизлайков: строки приглушены, вместо лайка — «снять дизлайк». */
disliked?: boolean;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const player = usePlayer(); const player = usePlayer();
const current = usePlayerState().track; const { track: current, playing } = usePlayerState();
const discs = new Set(tracks.map((tr) => tr.disc_no ?? 1)); const discs = new Set(tracks.map((tr) => tr.disc_no ?? 1));
let lastDisc: number | null = null; let lastDisc: number | null = null;
return ( return (
@@ -151,15 +157,16 @@ export function TrackList({
if (e.key === 'Enter' && e.target === e.currentTarget) play(); if (e.key === 'Enter' && e.target === e.currentTarget) play();
}} }}
> >
<span className={s.num}> {lead && <span className={s.lead}>{isCurrent ? <Equalizer playing={playing} /> : lead(tr, i)}</span>}
{numbered ? (tr.track_no ?? i + 1) : <Cover album={tr.album} size={40} />} <span className={cx(s.num, disliked && s.dimmed)}>
{numbered ? (tr.track_no ?? i + 1) : <Cover album={tr.album} size={lead ? 44 : 40} />}
{tr.available && ( {tr.available && (
<span className={s.playHint} aria-hidden> <span className={s.playHint} aria-hidden>
<Icon src={playIcon} size={20} /> <Icon src={playIcon} size={20} />
</span> </span>
)} )}
</span> </span>
<span className={s.trackMain}> <span className={cx(s.trackMain, disliked && s.dimmed)}>
<span className={s.trackTitle}>{tr.title}</span> <span className={s.trackTitle}>{tr.title}</span>
<span className={s.trackArtists}> <span className={s.trackArtists}>
<ArtistLinks artists={tr.artists} /> <ArtistLinks artists={tr.artists} />
@@ -172,6 +179,16 @@ export function TrackList({
</Link> </Link>
)} )}
<span className={s.end}> <span className={s.end}>
{disliked ? (
<button
type="button"
className={cx(s.like, s.liked)}
aria-label={t('player.undislike')}
onClick={() => void player.react(tr, null).catch(() => undefined)}
>
<Icon src={undoIcon} size={18} />
</button>
) : (
<button <button
type="button" type="button"
className={cx(s.like, liked && s.liked)} className={cx(s.like, liked && s.liked)}
@@ -181,6 +198,7 @@ export function TrackList({
> >
<Icon src={liked ? likeFillIcon : likeIcon} size={18} /> <Icon src={liked ? likeFillIcon : likeIcon} size={18} />
</button> </button>
)}
<span className={s.duration}>{formatDuration(tr.duration_ms)}</span> <span className={s.duration}>{formatDuration(tr.duration_ms)}</span>
</span> </span>
</div> </div>
@@ -191,6 +209,17 @@ export function TrackList({
); );
} }
/** Эквалайзер текущего трека (`pipuEq`, docs/design.md#2): три столбика, на паузе замирают. */
export function Equalizer({ playing }: { playing: boolean }) {
return (
<span className={cx(s.eq, !playing && s.eqPaused)} aria-hidden>
<span />
<span />
<span />
</span>
);
}
export function ArtistChip({ artist }: { artist: ArtistRef }) { export function ArtistChip({ artist }: { artist: ArtistRef }) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
+17
View File
@@ -0,0 +1,17 @@
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { useSession } from '../app/session';
/** Сколько лайков и дизлайков — счётчики в меню (total из первой страницы списка). */
export function useCollectionCounts() {
const session = useSession();
const total = (path: '/api/v1/me/likes' | '/api/v1/me/dislikes') => ({
queryKey: ['collection-count', path],
queryFn: async () => unwrap(await session.client.GET(path, { params: { query: { limit: 1 } } })).total,
staleTime: 60_000,
});
const likes = useQuery(total('/api/v1/me/likes'));
const dislikes = useQuery(total('/api/v1/me/dislikes'));
return { likes: likes.data, dislikes: dislikes.data };
}
+28 -10
View File
@@ -5,7 +5,10 @@ import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session'; import { useSession } from '../app/session';
import { Cover, TrackList } from '../entities/catalog'; import { Cover, TrackList } from '../entities/catalog';
import { usePlayer } from '../player/context';
import { formatDuration } from '../shared/lib/format'; import { formatDuration } from '../shared/lib/format';
import { playIcon, shuffleIcon } from '../shared/ui/icons';
import { Button } from '../shared/ui/ui';
import { NotFound } from './NotFound'; import { NotFound } from './NotFound';
import s from './page.module.css'; import s from './page.module.css';
import c from './catalog.module.css'; import c from './catalog.module.css';
@@ -13,6 +16,7 @@ import c from './catalog.module.css';
export function AlbumPage({ id }: { id: string }) { export function AlbumPage({ id }: { id: string }) {
const { t } = useTranslation(); const { t } = useTranslation();
const session = useSession(); const session = useSession();
const player = usePlayer();
const album = useQuery({ const album = useQuery({
queryKey: ['album', id], queryKey: ['album', id],
queryFn: async () => unwrap(await session.client.GET('/api/v1/albums/{id}', { params: { path: { id } } })), queryFn: async () => unwrap(await session.client.GET('/api/v1/albums/{id}', { params: { path: { id } } })),
@@ -21,17 +25,12 @@ export function AlbumPage({ id }: { id: string }) {
if (album.isError) return <NotFound error={album.error} />; if (album.isError) return <NotFound error={album.error} />;
const a = album.data; const a = album.data;
const colors = a?.cover_colors.length === 3 ? a.cover_colors : null; const colors = a?.cover_colors.length === 3 ? a.cover_colors : null;
const ctx = { type: 'album' as const, id, label: a?.title };
return ( return (
<div className={s.page}> <div className={s.page}>
<header className={c.hero}> <header className={c.hero}>
{colors && ( {colors && <span className={c.tint} style={{ background: `linear-gradient(180deg, ${colors[0]}66, transparent)` }} aria-hidden />}
<span
className={c.heroBg}
style={{ background: `linear-gradient(135deg, ${colors[0]}, ${colors[1]})` }}
aria-hidden
/>
)}
<Cover album={a ?? { id }} size={200} /> <Cover album={a ?? { id }} size={200} />
<div className={c.heroBody}> <div className={c.heroBody}>
<span className={c.kind}>{t('catalog.album')}</span> <span className={c.kind}>{t('catalog.album')}</span>
@@ -41,13 +40,32 @@ export function AlbumPage({ id }: { id: string }) {
<Link to="/artist/$id" params={{ id: a.artist.id }}> <Link to="/artist/$id" params={{ id: a.artist.id }}>
{a.artist.name} {a.artist.name}
</Link> </Link>
{a.year ? ` · ${a.year}` : ''} · {t('catalog.track_count', { count: a.tracks.length })} ·{' '} {a.year ? ` · ${a.year}` : ''} · {t('catalog.track_count', { count: a.tracks.length })} · {formatDuration(a.duration_ms)}
{formatDuration(a.duration_ms)}
</span> </span>
)} )}
{a && a.tracks.some((tr) => tr.available) && (
<div className={c.heroActions}>
<Button variant="primary" className={c.pill} icon={playIcon} onClick={() => player.playList(a.tracks, 0, ctx)}>
{t('catalog.play')}
</Button>
<Button
className={c.pill}
icon={shuffleIcon}
onClick={() =>
player.playList(
[...a.tracks].sort(() => Math.random() - 0.5),
0,
ctx,
)
}
>
{t('collection.shuffle')}
</Button>
</div>
)}
</div> </div>
</header> </header>
{a && <TrackList tracks={a.tracks} numbered context={{ type: 'album', id: a.id, label: a.title }} />} {a && <TrackList tracks={a.tracks} numbered context={ctx} />}
</div> </div>
); );
} }
+36 -3
View File
@@ -3,7 +3,10 @@ import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session'; import { useSession } from '../app/session';
import { AlbumGrid, Cover } from '../entities/catalog'; import { AlbumGrid, Cover, TrackList } from '../entities/catalog';
import { usePlayer } from '../player/context';
import { playIcon, waveIcon } from '../shared/ui/icons';
import { Button } from '../shared/ui/ui';
import { NotFound } from './NotFound'; import { NotFound } from './NotFound';
import s from './page.module.css'; import s from './page.module.css';
import c from './catalog.module.css'; import c from './catalog.module.css';
@@ -11,18 +14,25 @@ import c from './catalog.module.css';
export function ArtistPage({ id }: { id: string }) { export function ArtistPage({ id }: { id: string }) {
const { t } = useTranslation(); const { t } = useTranslation();
const session = useSession(); const session = useSession();
const player = usePlayer();
const artist = useQuery({ const artist = useQuery({
queryKey: ['artist', id], queryKey: ['artist', id],
queryFn: async () => unwrap(await session.client.GET('/api/v1/artists/{id}', { params: { path: { id } } })), queryFn: async () => unwrap(await session.client.GET('/api/v1/artists/{id}', { params: { path: { id } } })),
}); });
const top = useQuery({
queryKey: ['artist-top', id],
queryFn: async () =>
unwrap(await session.client.GET('/api/v1/artists/{id}/top-tracks', { params: { path: { id }, query: { limit: 6 } } })).items,
});
const albums = useQuery({ const albums = useQuery({
queryKey: ['artist-albums', id], queryKey: ['artist-albums', id],
queryFn: async () => queryFn: async () => unwrap(await session.client.GET('/api/v1/artists/{id}/albums', { params: { path: { id } } })).items,
unwrap(await session.client.GET('/api/v1/artists/{id}/albums', { params: { path: { id } } })).items,
}); });
if (artist.isError) return <NotFound error={artist.error} />; if (artist.isError) return <NotFound error={artist.error} />;
const a = artist.data; const a = artist.data;
const tracks = top.data ?? [];
const ctx = { type: 'artist' as const, id, label: a?.name };
const own = albums.data?.filter((al) => al.artist.id === id) ?? []; const own = albums.data?.filter((al) => al.artist.id === id) ?? [];
const appears = albums.data?.filter((al) => al.artist.id !== id) ?? []; const appears = albums.data?.filter((al) => al.artist.id !== id) ?? [];
@@ -38,8 +48,31 @@ export function ArtistPage({ id }: { id: string }) {
{t('catalog.album_count', { count: a.album_count })} · {t('catalog.track_count', { count: a.track_count })} {t('catalog.album_count', { count: a.album_count })} · {t('catalog.track_count', { count: a.track_count })}
</span> </span>
)} )}
{a && (
<div className={c.heroActions}>
<Button
variant="primary"
className={c.pill}
icon={waveIcon}
onClick={() => void player.startWave(null, null, { type: 'artist', id, label: a.name }).catch(() => undefined)}
>
{t('catalog.artist_wave')}
</Button>
{tracks.length > 0 && (
<Button className={c.pill} icon={playIcon} onClick={() => player.playList(tracks, 0, ctx)}>
{t('catalog.play')}
</Button>
)}
</div>
)}
</div> </div>
</header> </header>
{tracks.length > 0 && (
<section className={c.section}>
<h2>{t('catalog.top_tracks')}</h2>
<TrackList tracks={tracks} showAlbum context={ctx} lead={(_, i) => String(i + 1)} />
</section>
)}
{own.length > 0 && ( {own.length > 0 && (
<section className={c.section}> <section className={c.section}>
<h2>{t('catalog.albums')}</h2> <h2>{t('catalog.albums')}</h2>
+46 -10
View File
@@ -6,26 +6,46 @@ import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session'; import { useSession } from '../app/session';
import { TrackList, type Track } from '../entities/catalog'; import { TrackList, type Track } from '../entities/catalog';
import { useCollectionCounts } from '../entities/collection';
import { usePlayer } from '../player/context'; import { usePlayer } from '../player/context';
import { useErrorText } from '../shared/lib/errors'; import { useErrorText } from '../shared/lib/errors';
import { playIcon } from '../shared/ui/icons'; import { dislikeFillIcon, historyFillIcon, likeFillIcon, playIcon, shuffleIcon } from '../shared/ui/icons';
import { Button, Dialog, FormError } from '../shared/ui/ui'; import { Button, Dialog, FormError, Icon } from '../shared/ui/ui';
import c from './catalog.module.css'; import c from './catalog.module.css';
import s from './page.module.css'; import s from './page.module.css';
export type CollectionTab = 'history' | 'likes' | 'dislikes'; export type CollectionTab = 'history' | 'likes' | 'dislikes';
const TABS: CollectionTab[] = ['history', 'likes', 'dislikes']; const TABS: CollectionTab[] = ['history', 'likes', 'dislikes'];
const HERO: Record<CollectionTab, { icon: string; tile: string }> = {
history: { icon: historyFillIcon, tile: 'linear-gradient(140deg, #35D3FF, #2F80FF 60%, #1B3FA8)' },
likes: { icon: likeFillIcon, tile: 'linear-gradient(140deg, #5E9BFF, #2F80FF 50%, #7C5CFF)' },
dislikes: { icon: dislikeFillIcon, tile: 'linear-gradient(140deg, #4A5572, #1A2236)' },
};
export function CollectionPage({ tab }: { tab: CollectionTab }) { export function CollectionPage({ tab }: { tab: CollectionTab }) {
const { t } = useTranslation(); const { t } = useTranslation();
const counts = useCollectionCounts();
const hero = HERO[tab];
const sub =
tab === 'likes'
? t('collection.sub_likes', { count: counts.likes ?? 0 })
: t(tab === 'history' ? 'collection.sub_history' : 'collection.sub_dislikes');
return ( return (
<div className={s.page}> <div className={s.page}>
<div className={s.head}> <header className={c.colHero}>
<h1>{t('collection.title')}</h1> <span className={c.tile} style={{ background: hero.tile }} aria-hidden>
<Icon src={hero.icon} size={52} />
</span>
<div className={c.heroText}>
<span className={c.kicker}>{t('collection.title')}</span>
<h1 className={c.colTitle}>{t(`collection.${tab}`)}</h1>
<span className={c.heroSub}>{sub}</span>
</div> </div>
<div className={s.chips} role="tablist" aria-label={t('collection.title')}> </header>
<div className={c.underlineTabs} role="tablist" aria-label={t('collection.title')}>
{TABS.map((k) => ( {TABS.map((k) => (
<Link key={k} to={`/collection/${k}`} role="tab" aria-selected={tab === k} className={c.tab}> <Link key={k} to={`/collection/${k}`} role="tab" aria-selected={tab === k} className={c.underlineTab}>
{t(`collection.${k}`)} {t(`collection.${k}`)}
</Link> </Link>
))} ))}
@@ -92,11 +112,12 @@ function History() {
{history.isSuccess && items.length === 0 && <div className={s.empty}>{t('collection.empty_history')}</div>} {history.isSuccess && items.length === 0 && <div className={s.empty}>{t('collection.empty_history')}</div>}
{groups.map((g) => ( {groups.map((g) => (
<section key={g.key} className={c.section}> <section key={g.key} className={c.section}>
<h2>{g.title}</h2> <h2 className={c.day}>{g.title}</h2>
<TrackList <TrackList
tracks={g.items.map((i) => i.track)} tracks={g.items.map((i) => i.track)}
showAlbum
context={{ type: 'history' }} context={{ type: 'history' }}
rowMeta={(_, i) => time.format(new Date(g.items[i]!.played_at))} lead={(_, i) => time.format(new Date(g.items[i]!.played_at))}
/> />
</section> </section>
))} ))}
@@ -154,9 +175,16 @@ function Reacted({ reaction }: { reaction: 'like' | 'dislike' }) {
<> <>
{reaction === 'like' && tracks.length > 0 && ( {reaction === 'like' && tracks.length > 0 && (
<div className={s.toolbar}> <div className={s.toolbar}>
<Button variant="primary" icon={playIcon} onClick={() => player.playList(tracks, 0, context)}> <Button variant="primary" className={c.pill} icon={playIcon} onClick={() => player.playList(tracks, 0, context)}>
{t('collection.play_all')} {t('collection.play_all')}
</Button> </Button>
<Button
className={c.pill}
icon={shuffleIcon}
onClick={() => player.playList([...tracks].sort(() => Math.random() - 0.5), 0, context)}
>
{t('collection.shuffle')}
</Button>
<span className={s.note}>{t('catalog.track_count', { count: total })}</span> <span className={s.note}>{t('catalog.track_count', { count: total })}</span>
</div> </div>
)} )}
@@ -164,7 +192,15 @@ function Reacted({ reaction }: { reaction: 'like' | 'dislike' }) {
{list.isSuccess && tracks.length === 0 && ( {list.isSuccess && tracks.length === 0 && (
<div className={s.empty}>{t(reaction === 'like' ? 'collection.empty_likes' : 'collection.empty_dislikes')}</div> <div className={s.empty}>{t(reaction === 'like' ? 'collection.empty_likes' : 'collection.empty_dislikes')}</div>
)} )}
{tracks.length > 0 && <TrackList tracks={tracks} showAlbum context={context} />} {tracks.length > 0 && (
<TrackList
tracks={tracks}
showAlbum
context={context}
lead={(_, i) => String(i + 1)}
disliked={reaction === 'dislike'}
/>
)}
{list.hasNextPage && ( {list.hasNextPage && (
<Button className={s.more} busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}> <Button className={s.more} busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
{t('collection.more')} {t('collection.more')}
+24
View File
@@ -114,6 +114,30 @@ function AppearanceSection() {
]} ]}
/> />
</div> </div>
<div className={s.settingRow}>
<span>{t('settings.player_view')}</span>
<Segmented<'bar' | 'island'>
label={t('settings.player_view')}
value={user.ui_prefs.web_player ?? 'bar'}
onChange={(v) => void setPrefs({ web_player: v })}
options={[
{ value: 'bar', label: t('settings.player_bar') },
{ value: 'island', label: t('settings.player_island') },
]}
/>
</div>
<div className={s.settingRow}>
<span>{t('settings.ambient')}</span>
<Segmented<'on' | 'off'>
label={t('settings.ambient')}
value={user.ui_prefs.ambient === false ? 'off' : 'on'}
onChange={(v) => void setPrefs({ ambient: v === 'on' })}
options={[
{ value: 'on', label: t('settings.on') },
{ value: 'off', label: t('settings.off') },
]}
/>
</div>
<div className={s.settingRow}> <div className={s.settingRow}>
<span>{t('language.label')}</span> <span>{t('language.label')}</span>
<LanguageSwitch /> <LanguageSwitch />
@@ -0,0 +1,20 @@
import { useTranslation } from 'react-i18next';
import s from '../page.module.css';
import { UploadSettingsSection } from './UploadQueuePage';
/** «Настройки» админки: параметры сервера, которые меняет администратор (пока — очередь на добавление). */
export function AdminSettingsPage() {
const { t } = useTranslation('admin');
return (
<div className={s.page}>
<div className={s.head}>
<div>
<h1>{t('server_settings.title')}</h1>
<p className={s.subtitle}>{t('server_settings.subtitle')}</p>
</div>
</div>
<UploadSettingsSection />
</div>
);
}
+1 -3
View File
@@ -158,8 +158,6 @@ export function UploadQueuePage() {
</div> </div>
)} )}
<UploadSettingsSection />
{editing && ( {editing && (
<EditDialog <EditDialog
file={editing} file={editing}
@@ -441,7 +439,7 @@ function RejectDialog({
const GB = 1024 ** 3; const GB = 1024 ** 3;
/** Настройки очереди: общий лимит, лимит на модератора, размер файла, хранилище по умолчанию. */ /** Настройки очереди: общий лимит, лимит на модератора, размер файла, хранилище по умолчанию. */
function UploadSettingsSection() { export function UploadSettingsSection() {
const { t } = useTranslation(['admin', 'common']); const { t } = useTranslation(['admin', 'common']);
const session = useSession(); const session = useSession();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
+104
View File
@@ -66,6 +66,19 @@
pointer-events: none; pointer-events: none;
} }
.tint {
position: absolute;
inset: 0;
pointer-events: none;
}
.heroActions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 8px;
}
.heroBody { .heroBody {
position: relative; position: relative;
display: flex; display: flex;
@@ -114,3 +127,94 @@
padding: 20px 16px; padding: 20px 16px;
} }
} }
/* Шапка коллекции (макет «Коллекция»): плитка-иконка, надзаголовок, крупный заголовок. */
.colHero {
display: flex;
align-items: flex-end;
flex-wrap: wrap;
gap: 24px;
}
.tile {
display: grid;
place-items: center;
width: 120px;
height: 120px;
border-radius: 20px;
color: #fff;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.3);
}
.heroText {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
min-width: 240px;
}
.kicker {
font-size: 13px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text2);
}
.colTitle {
margin: 0;
font-family: var(--font-display);
font-size: 40px;
font-weight: 600;
letter-spacing: -0.02em;
line-height: 1;
}
.heroSub {
font-size: 14px;
color: var(--text2);
text-wrap: pretty;
}
.underlineTabs {
display: flex;
gap: 6px;
border-bottom: 1px solid var(--line);
}
.underlineTab {
display: inline-flex;
align-items: center;
height: 42px;
padding: 0 14px;
margin-bottom: -1px;
border-bottom: 2px solid transparent;
color: var(--text2);
font-size: 15px;
font-weight: 500;
text-decoration: none;
}
.underlineTab:hover {
color: var(--text);
}
.underlineTab[aria-selected='true'] {
color: var(--text);
border-bottom-color: var(--accent);
}
.day {
margin: 0;
padding: 8px 12px;
font-family: inherit;
font-size: 14px;
font-weight: 600;
color: var(--text2);
}
.pill {
height: 44px;
border-radius: var(--radius-pill);
}
+5 -3
View File
@@ -164,18 +164,20 @@ export class Player {
// --- «Моя волна» --- // --- «Моя волна» ---
/** Запустить волну (или перезапустить с новым контекстом). */ /** Запустить волну (или перезапустить с новым контекстом). */
async startWave(activity: string | null, mood: string | null) { async startWave(activity: string | null, mood: string | null, seed?: { type: 'artist' | 'album' | 'track'; id: string; label: string }) {
this.finishAttempt('skip'); this.finishAttempt('skip');
this.leaveWave(); this.leaveWave();
const res = unwrap( const res = unwrap(
await this.session.client.POST('/api/v1/wave/sessions', { body: { activity, mood } }), await this.session.client.POST('/api/v1/wave/sessions', {
body: { activity, mood, ...(seed ? { seed: { type: seed.type, id: seed.id } } : {}) },
}),
); );
const tracks = res.tracks.filter((t) => t.available && t.stream_url); const tracks = res.tracks.filter((t) => t.available && t.stream_url);
if (!tracks.length) { if (!tracks.length) {
this.update({ error: 'wave.empty' }); this.update({ error: 'wave.empty' });
return; return;
} }
this.context = { type: 'wave', id: res.session_id }; this.context = { type: 'wave', id: res.session_id, label: seed?.label };
this.queue.shuffle = false; this.queue.shuffle = false;
this.queue.repeat = 'off'; this.queue.repeat = 'off';
this.queue.set(tracks, 0); this.queue.set(tracks, 0);
+5
View File
@@ -63,3 +63,8 @@ export { default as myUploadsIcon } from '@material-symbols/svg-400/rounded/libr
export { default as myUploadsFillIcon } from '@material-symbols/svg-400/rounded/library_add-fill.svg'; export { default as myUploadsFillIcon } from '@material-symbols/svg-400/rounded/library_add-fill.svg';
export { default as inboxIcon } from '@material-symbols/svg-400/rounded/inbox.svg'; export { default as inboxIcon } from '@material-symbols/svg-400/rounded/inbox.svg';
export { default as inboxFillIcon } from '@material-symbols/svg-400/rounded/inbox-fill.svg'; export { default as inboxFillIcon } from '@material-symbols/svg-400/rounded/inbox-fill.svg';
export { default as waveIcon } from '@material-symbols/svg-400/rounded/graphic_eq.svg';
export { default as tuneIcon } from '@material-symbols/svg-400/rounded/tune.svg';
export { default as tuneFillIcon } from '@material-symbols/svg-400/rounded/tune-fill.svg';
export { default as queueIcon } from '@material-symbols/svg-400/rounded/queue_music.svg';
export { default as undoIcon } from '@material-symbols/svg-400/rounded/undo.svg';
+10
View File
@@ -61,3 +61,13 @@ button {
transition-duration: 0.01ms !important; transition-duration: 0.01ms !important;
} }
} }
/* Эквалайзер текущего трека (docs/design.md#2). */
@keyframes pipuEq {
from {
transform: scaleY(0.25);
}
to {
transform: scaleY(1);
}
}
+108
View File
@@ -0,0 +1,108 @@
.ambient {
position: fixed;
inset: 0;
z-index: 0;
overflow: hidden;
pointer-events: none;
opacity: var(--amb-op);
animation: pipuHue 30s linear infinite;
}
.ambient span {
position: absolute;
border-radius: 50%;
filter: blur(30px);
will-change: transform;
}
.a {
left: -22vmax;
top: -28vmax;
width: 70vmax;
height: 70vmax;
background: radial-gradient(circle, rgba(47, 128, 255, 0.75) 0%, transparent 62%);
animation: pipuDrift1 16s ease-in-out infinite alternate;
}
.b {
right: -20vmax;
top: 8vh;
width: 60vmax;
height: 60vmax;
background: radial-gradient(circle, rgba(124, 92, 255, 0.6) 0%, transparent 62%);
animation: pipuDrift2 21s ease-in-out infinite alternate;
}
.c {
left: 22vw;
bottom: -32vmax;
width: 55vmax;
height: 55vmax;
background: radial-gradient(circle, var(--amb-c3, rgba(53, 211, 255, 0.6)) 0%, transparent 62%);
animation: pipuDrift3 26s ease-in-out infinite alternate;
transition: background 1.2s;
}
@keyframes pipuDrift1 {
0% {
transform: translate(-6%, -4%) scale(1);
}
35% {
transform: translate(45%, 28%) scale(1.2);
}
70% {
transform: translate(15%, 60%) scale(0.9);
}
100% {
transform: translate(60%, -5%) scale(1.1);
}
}
@keyframes pipuDrift2 {
0% {
transform: translate(4%, 0) scale(1.05);
}
40% {
transform: translate(-50%, 40%) scale(0.9);
}
75% {
transform: translate(-20%, -25%) scale(1.2);
}
100% {
transform: translate(-60%, 15%) scale(1);
}
}
@keyframes pipuDrift3 {
0% {
transform: translate(0, 0) scale(0.95);
}
30% {
transform: translate(-40%, -45%) scale(1.15);
}
65% {
transform: translate(45%, -25%) scale(1);
}
100% {
transform: translate(10%, -55%) scale(1.2);
}
}
@keyframes pipuHue {
0% {
filter: hue-rotate(0deg);
}
50% {
filter: hue-rotate(-35deg);
}
100% {
filter: hue-rotate(0deg);
}
}
@media (prefers-reduced-motion: reduce) {
.ambient,
.ambient span {
animation: none;
}
}
+17
View File
@@ -0,0 +1,17 @@
import type { CSSProperties } from 'react';
import s from './Ambient.module.css';
/**
* Ambient-фон (docs/design.md#1): три размытых дрейфующих пятна; третье — в цвете обложки текущего трека.
* Отключается в настройках; при prefers-reduced-motion пятна неподвижны.
*/
export function Ambient({ color }: { color: string }) {
return (
<div className={s.ambient} aria-hidden>
<span className={s.a} />
<span className={s.b} />
<span className={s.c} style={{ '--amb-c3': `${color}99` } as CSSProperties} />
</div>
);
}
+51
View File
@@ -1,4 +1,5 @@
.shell { .shell {
position: relative;
display: grid; display: grid;
grid-template-columns: 240px 1fr; grid-template-columns: 240px 1fr;
grid-template-rows: 60px 1fr 88px; grid-template-rows: 60px 1fr 88px;
@@ -9,6 +10,23 @@
height: 100%; height: 100%;
} }
.shell > :where(:not([aria-hidden])) {
position: relative;
z-index: 1;
}
/* Плеер-остров: плавает над контентом, контенту — запас снизу (docs/design.md#4). */
.island {
grid-template-rows: 60px 1fr;
grid-template-areas:
'top top'
'side content';
}
.island .content {
padding-bottom: 132px;
}
.top { .top {
grid-area: top; grid-area: top;
display: flex; display: flex;
@@ -118,6 +136,33 @@
color: var(--text3); color: var(--text3);
} }
.navLabel {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.navCount {
font-size: 12px;
color: var(--text3);
font-variant-numeric: tabular-nums;
}
.navBadge {
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: var(--radius-pill);
background: var(--accent);
color: #fff;
font-size: 11px;
font-weight: 700;
line-height: 20px;
text-align: center;
}
.navItem[data-status='active'] .navIcon { .navItem[data-status='active'] .navIcon {
color: var(--accent2); color: var(--accent2);
} }
@@ -140,6 +185,12 @@
font-size: 0; font-size: 0;
} }
.navLabel,
.navCount,
.navBadge {
display: none;
}
.navSection { .navSection {
margin: 14px 0 6px; margin: 14px 0 6px;
text-align: center; text-align: center;
+73 -15
View File
@@ -1,7 +1,10 @@
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { Link, Outlet, useNavigate } from '@tanstack/react-router'; import { Link, Outlet, useNavigate } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAuthState, useSession, useUser } from '../app/session'; import { useAuthState, useSession, useUser } from '../app/session';
import { useCollectionCounts } from '../entities/collection';
import { import {
auditFillIcon, auditFillIcon,
auditIcon, auditIcon,
@@ -20,10 +23,11 @@ import {
dislikeIcon, dislikeIcon,
settingsIcon, settingsIcon,
storageFillIcon, storageFillIcon,
storageIcon, inboxFillIcon, inboxIcon, myUploadsFillIcon, myUploadsIcon, uploadFillIcon, uploadIcon} from '../shared/ui/icons'; storageIcon, inboxFillIcon, inboxIcon, myUploadsFillIcon, myUploadsIcon, uploadFillIcon, uploadIcon, tuneFillIcon, tuneIcon} from '../shared/ui/icons';
import { Avatar, Icon, Logo, Menu } from '../shared/ui/ui'; import { Avatar, cx, Icon, Logo, Menu } from '../shared/ui/ui';
import { LanguageSwitch } from './LanguageSwitch'; import { LanguageSwitch } from './LanguageSwitch';
import { PlayerProvider } from '../player/context'; import { PlayerProvider, usePlayerState } from '../player/context';
import { Ambient } from './Ambient';
import { PlayerBar } from './PlayerBar'; import { PlayerBar } from './PlayerBar';
import { SearchBox } from './SearchBox'; import { SearchBox } from './SearchBox';
import s from './AppShell.module.css'; import s from './AppShell.module.css';
@@ -37,17 +41,34 @@ type NavPath =
| '/admin/uploads' | '/admin/uploads'
| '/uploads/new' | '/uploads/new'
| '/uploads/mine' | '/uploads/mine'
| '/admin/settings'
| '/collection/history' | '/collection/history'
| '/collection/likes' | '/collection/likes'
| '/collection/dislikes'; | '/collection/dislikes';
function NavLink({ to, label, icon, activeIcon }: { to: NavPath; label: string; icon: string; activeIcon: string }) { function NavLink({
to,
label,
icon,
activeIcon,
count,
badge,
}: {
to: NavPath;
label: string;
icon: string;
activeIcon: string;
count?: number; // счётчик справа (лайки, дизлайки)
badge?: number; // яркий бейдж (ждут решения)
}) {
return ( return (
<Link to={to} className={s.navItem} activeOptions={{ exact: to === '/' }}> <Link to={to} className={s.navItem} activeOptions={{ exact: to === '/' }}>
{({ isActive }) => ( {({ isActive }) => (
<> <>
<Icon src={isActive ? activeIcon : icon} className={s.navIcon} /> <Icon src={isActive ? activeIcon : icon} className={s.navIcon} />
{label} <span className={s.navLabel}>{label}</span>
{badge ? <span className={s.navBadge}>{badge > 99 ? '99+' : badge}</span> : null}
{!badge && count ? <span className={s.navCount}>{count}</span> : null}
</> </>
)} )}
</Link> </Link>
@@ -70,9 +91,13 @@ function Shell() {
const navigate = useNavigate(); const navigate = useNavigate();
const name = user.display_name ?? user.username; const name = user.display_name ?? user.username;
const staff = user.role !== 'user'; const staff = user.role !== 'user';
const island = user.ui_prefs.web_player === 'island';
const counts = useCollectionCounts();
const pending = usePendingUploads(user.role === 'admin');
return ( return (
<div className={s.shell}> <div className={cx(s.shell, island && s.island)}>
{user.ui_prefs.ambient !== false && <ShellAmbient />}
<header className={s.top}> <header className={s.top}>
<Link to="/" className={s.logoLink}> <Link to="/" className={s.logoLink}>
<Logo /> <Logo />
@@ -107,24 +132,36 @@ function Shell() {
<NavLink to="/" label={t('nav.home')} icon={homeIcon} activeIcon={homeFillIcon} /> <NavLink to="/" label={t('nav.home')} icon={homeIcon} activeIcon={homeFillIcon} />
<div className={s.navSection}>{t('nav.collection')}</div> <div className={s.navSection}>{t('nav.collection')}</div>
<NavLink to="/collection/history" label={t('nav.history')} icon={historyIcon} activeIcon={historyFillIcon} /> <NavLink to="/collection/history" label={t('nav.history')} icon={historyIcon} activeIcon={historyFillIcon} />
<NavLink to="/collection/likes" label={t('nav.likes')} icon={likeIcon} activeIcon={likeFillIcon} /> <NavLink to="/collection/likes" label={t('nav.likes')} icon={likeIcon} activeIcon={likeFillIcon} count={counts.likes} />
<NavLink to="/collection/dislikes" label={t('nav.dislikes')} icon={dislikeIcon} activeIcon={dislikeFillIcon} /> <NavLink
to="/collection/dislikes"
label={t('nav.dislikes')}
icon={dislikeIcon}
activeIcon={dislikeFillIcon}
count={counts.dislikes}
/>
{staff && ( {staff && (
<> <>
<div className={s.navSection}>{t('nav.uploads')}</div>
<NavLink to="/uploads/new" label={t('nav.suggest')} icon={uploadIcon} activeIcon={uploadFillIcon} />
<NavLink to="/uploads/mine" label={t('nav.my_uploads')} icon={myUploadsIcon} activeIcon={myUploadsFillIcon} />
{user.role === 'admin' && (
<NavLink to="/admin/uploads" label={t('nav.upload_queue')} icon={inboxIcon} activeIcon={inboxFillIcon} />
)}
<div className={s.navSection}>{t('nav.admin')}</div> <div className={s.navSection}>{t('nav.admin')}</div>
<NavLink to="/admin/users" label={t('nav.users')} icon={groupIcon} activeIcon={groupFillIcon} /> <NavLink to="/admin/users" label={t('nav.users')} icon={groupIcon} activeIcon={groupFillIcon} />
{user.role === 'admin' && ( {user.role === 'admin' && (
<NavLink to="/admin/sources" label={t('nav.sources')} icon={storageIcon} activeIcon={storageFillIcon} /> <NavLink to="/admin/sources" label={t('nav.sources')} icon={storageIcon} activeIcon={storageFillIcon} />
)} )}
<NavLink to="/admin/library" label={t('nav.library')} icon={libraryIcon} activeIcon={libraryFillIcon} /> <NavLink to="/admin/library" label={t('nav.library')} icon={libraryIcon} activeIcon={libraryFillIcon} />
<NavLink to="/uploads/new" label={t('nav.suggest')} icon={uploadIcon} activeIcon={uploadFillIcon} />
<NavLink to="/uploads/mine" label={t('nav.my_uploads')} icon={myUploadsIcon} activeIcon={myUploadsFillIcon} />
{user.role === 'admin' && ( {user.role === 'admin' && (
<>
<NavLink
to="/admin/uploads"
label={t('nav.upload_queue')}
icon={inboxIcon}
activeIcon={inboxFillIcon}
badge={pending}
/>
<NavLink to="/admin/settings" label={t('nav.admin_settings')} icon={tuneIcon} activeIcon={tuneFillIcon} />
<NavLink to="/admin/audit" label={t('nav.audit')} icon={auditIcon} activeIcon={auditFillIcon} /> <NavLink to="/admin/audit" label={t('nav.audit')} icon={auditIcon} activeIcon={auditFillIcon} />
</>
)} )}
</> </>
)} )}
@@ -134,7 +171,28 @@ function Shell() {
<Outlet /> <Outlet />
</main> </main>
<PlayerBar /> <PlayerBar variant={island ? 'island' : 'bar'} />
</div> </div>
); );
} }
/** Третье пятно фона — в цвете обложки текущего трека. */
function ShellAmbient() {
const track = usePlayerState().track;
return <Ambient color={track?.album.cover_colors[0] ?? '#35D3FF'} />;
}
/** Файлов на рассмотрении — бейдж «Очереди» у админа (docs/uploads.md#10). */
function usePendingUploads(enabled: boolean) {
const session = useSession();
const q = useQuery({
queryKey: ['admin-uploads-pending'],
enabled,
queryFn: async () =>
unwrap(
await session.client.GET('/api/v1/admin/uploads', { params: { query: { status: ['pending_review'], limit: 100 } } }),
).items.length,
refetchInterval: 60_000,
});
return q.data;
}
+97
View File
@@ -10,6 +10,45 @@
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
} }
.bar.island {
position: fixed;
left: 50%;
bottom: 16px;
z-index: 5;
width: min(1040px, calc(100% - 32px));
height: 84px;
transform: translateX(-50%);
border: 1px solid var(--line2);
border-radius: var(--radius-xl);
background: var(--glass);
backdrop-filter: blur(28px) saturate(1.3);
box-shadow: var(--shadow);
}
.source {
display: flex;
align-items: center;
gap: 5px;
margin-top: 2px;
font-size: 12px;
color: var(--text3);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
a.source {
text-decoration: none;
}
a.source:hover {
color: var(--text);
}
.sourceWave {
color: var(--accent2);
}
.empty { .empty {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -192,3 +231,61 @@
display: none; display: none;
} }
} }
.drawer {
position: fixed;
right: 16px;
bottom: 100px;
z-index: 6;
display: flex;
flex-direction: column;
width: min(380px, calc(100% - 32px));
max-height: min(560px, calc(100% - 180px));
border: 1px solid var(--line2);
border-radius: var(--radius-xl);
background: var(--glass);
backdrop-filter: blur(28px) saturate(1.3);
box-shadow: var(--shadow);
animation: pipuIn 0.2s ease-out;
}
.drawerIsland {
bottom: 112px;
}
.drawerHead {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 12px 10px 18px;
font-weight: 600;
font-size: 16px;
}
.drawerList {
overflow-y: auto;
padding: 0 8px 10px;
}
.queueRow {
display: grid;
grid-template-columns: 40px minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
width: 100%;
padding: 6px 10px;
border-radius: 12px;
text-align: left;
}
.queueRow:hover {
background: var(--hover);
}
.queueCurrent {
background: var(--accent-soft);
}
.queueCurrent .title {
color: var(--accent2);
}
+110 -34
View File
@@ -1,10 +1,14 @@
import type { CSSProperties } from 'react'; import { useState, type CSSProperties } from 'react';
import { createPortal } from 'react-dom';
import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ArtistLinks, Cover } from '../entities/catalog'; import { ArtistLinks, Cover } from '../entities/catalog';
import { usePlayer, usePlayerState } from '../player/context'; import { usePlayer, usePlayerState } from '../player/context';
import { formatDuration } from '../shared/lib/format'; import { formatDuration } from '../shared/lib/format';
import { import {
albumIcon,
closeIcon,
dislikeFillIcon, dislikeFillIcon,
dislikeIcon, dislikeIcon,
likeFillIcon, likeFillIcon,
@@ -14,25 +18,28 @@ import {
pauseIcon, pauseIcon,
playIcon, playIcon,
prevIcon, prevIcon,
queueIcon,
repeatIcon, repeatIcon,
repeatOneIcon, repeatOneIcon,
shuffleIcon, shuffleIcon,
volumeIcon, volumeIcon,
volumeOffIcon, volumeOffIcon,
waveIcon,
} from '../shared/ui/icons'; } from '../shared/ui/icons';
import { cx, Icon } from '../shared/ui/ui'; import { cx, Icon } from '../shared/ui/ui';
import s from './PlayerBar.module.css'; import s from './PlayerBar.module.css';
/** Плеер внизу экрана (вид `bar`, docs/design.md). */ /** Плеер: полоса внизу (`bar`, по умолчанию) или плавающий остров (`island`) — выбор в настройках (docs/design.md). */
export function PlayerBar() { export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
const { t } = useTranslation(); const { t } = useTranslation();
const player = usePlayer(); const player = usePlayer();
const st = usePlayerState(); const st = usePlayerState();
const [queueOpen, setQueueOpen] = useState(false);
const track = st.track; const track = st.track;
if (!track) { if (!track) {
return ( return (
<footer className={s.bar} aria-label={t('player.label')}> <footer className={cx(s.bar, variant === 'island' && s.island)} aria-label={t('player.label')}>
<span className={s.empty}> <span className={s.empty}>
<span className={s.placeholder}> <span className={s.placeholder}>
<Icon src={musicIcon} size={22} /> <Icon src={musicIcon} size={22} />
@@ -43,12 +50,22 @@ export function PlayerBar() {
); );
} }
const react = (r: 'like' | 'dislike') => const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
const repeatLabel = t(`player.repeat_${st.repeat}`); const repeatLabel = t(`player.repeat_${st.repeat}`);
const ctx = st.context;
const tr = t as unknown as (k: string, o?: Record<string, unknown>) => string;
const source = !ctx
? null
: ctx.type === 'wave'
? ctx.label
? t('player.source_wave_seed', { label: ctx.label })
: t('player.source_wave')
: ctx.type === 'collection'
? tr(`player.source_${ctx.id === 'dislikes' ? 'dislikes' : 'likes'}`)
: tr(`player.source_${ctx.type}`, { label: ctx.label ?? '' });
return ( return (
<footer className={s.bar} aria-label={t('player.label')}> <footer className={cx(s.bar, variant === 'island' && s.island)} aria-label={t('player.label')}>
<div className={s.now}> <div className={s.now}>
<Cover album={track.album} size={52} /> <Cover album={track.album} size={52} />
<div className={s.meta}> <div className={s.meta}>
@@ -56,7 +73,23 @@ export function PlayerBar() {
<span className={s.artists}> <span className={s.artists}>
<ArtistLinks artists={track.artists} /> <ArtistLinks artists={track.artists} />
</span> </span>
{source &&
(ctx?.type === 'album' && ctx.id ? (
<Link to="/album/$id" params={{ id: ctx.id }} className={s.source}>
<Icon src={albumIcon} size={14} />
{source}
</Link>
) : (
<span className={cx(s.source, ctx?.type === 'wave' && s.sourceWave)}>
<Icon src={ctx?.type === 'wave' ? waveIcon : albumIcon} size={14} />
{source}
</span>
))}
</div> </div>
</div>
<div className={s.center}>
<div className={s.controls}>
<button <button
type="button" type="button"
className={cx(s.icon, track.reaction === 'dislike' && s.danger)} className={cx(s.icon, track.reaction === 'dislike' && s.danger)}
@@ -66,28 +99,6 @@ export function PlayerBar() {
> >
<Icon src={track.reaction === 'dislike' ? dislikeFillIcon : dislikeIcon} /> <Icon src={track.reaction === 'dislike' ? dislikeFillIcon : dislikeIcon} />
</button> </button>
<button
type="button"
className={cx(s.icon, track.reaction === 'like' && s.accent)}
aria-label={t(track.reaction === 'like' ? 'player.unlike' : 'player.like')}
aria-pressed={track.reaction === 'like'}
onClick={() => react('like')}
>
<Icon src={track.reaction === 'like' ? likeFillIcon : likeIcon} />
</button>
</div>
<div className={s.center}>
<div className={s.controls}>
<button
type="button"
className={cx(s.icon, st.shuffle && s.accent)}
aria-label={t('player.shuffle')}
aria-pressed={st.shuffle}
onClick={() => player.toggleShuffle()}
>
<Icon src={shuffleIcon} size={20} />
</button>
<button type="button" className={s.icon} aria-label={t('player.prev')} onClick={() => player.prev()}> <button type="button" className={s.icon} aria-label={t('player.prev')} onClick={() => player.prev()}>
<Icon src={prevIcon} size={26} /> <Icon src={prevIcon} size={26} />
</button> </button>
@@ -105,12 +116,12 @@ export function PlayerBar() {
</button> </button>
<button <button
type="button" type="button"
className={cx(s.icon, st.repeat !== 'off' && s.accent)} className={cx(s.icon, track.reaction === 'like' && s.accent)}
aria-label={repeatLabel} aria-label={t(track.reaction === 'like' ? 'player.unlike' : 'player.like')}
title={repeatLabel} aria-pressed={track.reaction === 'like'}
onClick={() => player.cycleRepeat()} onClick={() => react('like')}
> >
<Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={20} /> <Icon src={track.reaction === 'like' ? likeFillIcon : likeIcon} />
</button> </button>
</div> </div>
<div className={s.progress}> <div className={s.progress}>
@@ -137,6 +148,33 @@ export function PlayerBar() {
</div> </div>
<div className={s.right}> <div className={s.right}>
<button
type="button"
className={cx(s.icon, st.shuffle && s.accent)}
aria-label={t('player.shuffle')}
aria-pressed={st.shuffle}
onClick={() => player.toggleShuffle()}
>
<Icon src={shuffleIcon} size={20} />
</button>
<button
type="button"
className={cx(s.icon, st.repeat !== 'off' && s.accent)}
aria-label={repeatLabel}
title={repeatLabel}
onClick={() => player.cycleRepeat()}
>
<Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={20} />
</button>
<button
type="button"
className={cx(s.icon, queueOpen && s.accent)}
aria-label={t('player.queue')}
aria-expanded={queueOpen}
onClick={() => setQueueOpen((v) => !v)}
>
<Icon src={queueIcon} size={20} />
</button>
<button <button
type="button" type="button"
className={s.icon} className={s.icon}
@@ -157,6 +195,44 @@ export function PlayerBar() {
style={{ '--pct': `${(st.muted ? 0 : st.volume) * 100}%` } as CSSProperties} style={{ '--pct': `${(st.muted ? 0 : st.volume) * 100}%` } as CSSProperties}
/> />
</div> </div>
{queueOpen && <QueueDrawer variant={variant} onClose={() => setQueueOpen(false)} />}
</footer> </footer>
); );
} }
/** Очередь воспроизведения (выдвижная панель справа): что играет и что дальше, клик — перейти к треку. */
function QueueDrawer({ variant, onClose }: { variant: 'bar' | 'island'; onClose: () => void }) {
const { t } = useTranslation();
const player = usePlayer();
const st = usePlayerState();
const upcoming = st.queue.slice(st.index, st.index + 40);
// Портал: у плеера backdrop-filter, а он делает fixed-потомков относительными к себе.
return createPortal(
<aside className={cx(s.drawer, variant === 'island' && s.drawerIsland)} aria-label={t('player.queue')}>
<div className={s.drawerHead}>
<span>{t('player.queue')}</span>
<button type="button" className={s.icon} aria-label={t('form.close')} onClick={onClose}>
<Icon src={closeIcon} size={20} />
</button>
</div>
<div className={s.drawerList}>
{upcoming.map((tr, k) => (
<button
key={`${tr.id}-${k}`}
type="button"
className={cx(s.queueRow, k === 0 && s.queueCurrent)}
onClick={() => player.jump(st.index + k)}
>
<Cover album={tr.album} size={40} />
<span className={s.meta}>
<span className={s.title}>{tr.title}</span>
<span className={s.artists}>{tr.artists.map((a) => a.name).join(', ')}</span>
</span>
<span className={s.time}>{formatDuration(tr.duration_ms)}</span>
</button>
))}
</div>
</aside>,
document.body,
);
}
@@ -781,6 +781,25 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/artists/{id}/top-tracks": {
parameters: {
query?: never;
header?: never;
path: {
id: components["parameters"]["EntityId"];
};
cookie?: never;
};
/** Популярные треки исполнителя (по прослушиваниям всех пользователей) */
get: operations["listArtistTopTracks"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/albums/{id}": { "/api/v1/albums/{id}": {
parameters: { parameters: {
query?: never; query?: never;
@@ -3149,6 +3168,31 @@ export interface operations {
default: components["responses"]["Problem"]; default: components["responses"]["Problem"];
}; };
}; };
listArtistTopTracks: {
parameters: {
query?: {
limit?: number;
};
header?: never;
path: {
id: components["parameters"]["EntityId"];
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Треки, самые слушаемые первыми */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TrackList"];
};
};
default: components["responses"]["Problem"];
};
};
getAlbum: { getAlbum: {
parameters: { parameters: {
query?: never; query?: never;
+4
View File
@@ -327,5 +327,9 @@
"rejected": "Not accepted", "rejected": "Not accepted",
"error": "Error" "error": "Error"
} }
},
"server_settings": {
"title": "Server settings",
"subtitle": "Settings that apply to all users"
} }
} }
+32 -5
View File
@@ -33,7 +33,8 @@
"uploads": "Uploads", "uploads": "Uploads",
"suggest": "Suggest tracks", "suggest": "Suggest tracks",
"my_uploads": "My uploads", "my_uploads": "My uploads",
"upload_queue": "Upload queue" "upload_queue": "Queue",
"admin_settings": "Settings"
}, },
"profile": { "profile": {
"menu": "Profile menu", "menu": "Profile menu",
@@ -70,7 +71,17 @@
"repeat_one": "Repeat track", "repeat_one": "Repeat track",
"progress": "Position", "progress": "Position",
"unavailable": "Track is unavailable — skipping", "unavailable": "Track is unavailable — skipping",
"error": "Could not play the track" "error": "Could not play the track",
"source_album": "Album · {{label}}",
"source_artist": "Artist · {{label}}",
"source_search": "Search · {{label}}",
"source_history": "History",
"source_likes": "Likes",
"source_dislikes": "Dislikes",
"source_wave": "My Wave",
"source_queue": "Queue",
"queue": "Queue",
"source_wave_seed": "Artist wave · {{label}}"
}, },
"settings": { "settings": {
"title": "Settings", "title": "Settings",
@@ -96,7 +107,13 @@
"logout_all": "Sign out everywhere", "logout_all": "Sign out everywhere",
"wave_variant": "Wave style", "wave_variant": "Wave style",
"wave_orb": "Orb", "wave_orb": "Orb",
"wave_liquid": "Liquid" "wave_liquid": "Liquid",
"player_view": "Player style",
"player_bar": "Bar",
"player_island": "Island",
"ambient": "Ambient background",
"on": "On",
"off": "Off"
}, },
"form": { "form": {
"show_password": "Show password", "show_password": "Show password",
@@ -146,7 +163,10 @@
"col_duration": "Time", "col_duration": "Time",
"back_home": "Back home", "back_home": "Back home",
"artist": "Artist", "artist": "Artist",
"album": "Album" "album": "Album",
"play": "Play",
"artist_wave": "Artist wave",
"top_tracks": "Popular tracks"
}, },
"collection": { "collection": {
"title": "Collection", "title": "Collection",
@@ -161,7 +181,14 @@
"empty_likes": "Tap the heart on tracks — they will gather here", "empty_likes": "Tap the heart on tracks — they will gather here",
"empty_dislikes": "Disliked tracks stay out of the wave. None so far", "empty_dislikes": "Disliked tracks stay out of the wave. None so far",
"play_all": "Play all", "play_all": "Play all",
"more": "Show more" "more": "Show more",
"sub_history": "Everything you listened to, by day. Click a track to play it.",
"sub_likes_one": "{{count}} track · they shape My Wave the most",
"sub_likes_few": "{{count}} tracks · they shape My Wave the most",
"sub_likes_many": "{{count}} tracks · they shape My Wave the most",
"sub_likes_other": "{{count}} tracks · they shape My Wave the most",
"sub_dislikes": "These tracks never get into the wave or recommendations. Remove the dislike if you change your mind.",
"shuffle": "Shuffle"
}, },
"wave": { "wave": {
"title": "My Wave", "title": "My Wave",
+4
View File
@@ -327,5 +327,9 @@
"rejected": "Не принят", "rejected": "Не принят",
"error": "Ошибка" "error": "Ошибка"
} }
},
"server_settings": {
"title": "Настройки сервера",
"subtitle": "Параметры, которые действуют для всех пользователей"
} }
} }
+32 -5
View File
@@ -33,7 +33,8 @@
"uploads": "Предложения", "uploads": "Предложения",
"suggest": "Предложить треки", "suggest": "Предложить треки",
"my_uploads": "Мои предложения", "my_uploads": "Мои предложения",
"upload_queue": "Очередь на добавление" "upload_queue": "Очередь",
"admin_settings": "Настройки"
}, },
"profile": { "profile": {
"menu": "Меню профиля", "menu": "Меню профиля",
@@ -70,7 +71,17 @@
"repeat_one": "Повторять трек", "repeat_one": "Повторять трек",
"progress": "Позиция", "progress": "Позиция",
"unavailable": "Трек недоступен — пропускаем", "unavailable": "Трек недоступен — пропускаем",
"error": "Не удалось воспроизвести трек" "error": "Не удалось воспроизвести трек",
"source_album": "Альбом · {{label}}",
"source_artist": "Исполнитель · {{label}}",
"source_search": "Поиск · {{label}}",
"source_history": "История",
"source_likes": "Лайки",
"source_dislikes": "Дизлайки",
"source_wave": "Моя волна",
"source_queue": "Очередь",
"queue": "Очередь",
"source_wave_seed": "Волна по исполнителю · {{label}}"
}, },
"settings": { "settings": {
"title": "Настройки", "title": "Настройки",
@@ -96,7 +107,13 @@
"logout_all": "Выйти на всех устройствах", "logout_all": "Выйти на всех устройствах",
"wave_variant": "Вид волны", "wave_variant": "Вид волны",
"wave_orb": "Сфера", "wave_orb": "Сфера",
"wave_liquid": "Жидкая" "wave_liquid": "Жидкая",
"player_view": "Вид плеера",
"player_bar": "Полоса",
"player_island": "Остров",
"ambient": "Живой фон",
"on": "Вкл.",
"off": "Выкл."
}, },
"form": { "form": {
"show_password": "Показать пароль", "show_password": "Показать пароль",
@@ -146,7 +163,10 @@
"col_duration": "Время", "col_duration": "Время",
"back_home": "На главную", "back_home": "На главную",
"artist": "Исполнитель", "artist": "Исполнитель",
"album": "Альбом" "album": "Альбом",
"play": "Слушать",
"artist_wave": "Волна по исполнителю",
"top_tracks": "Популярные треки"
}, },
"collection": { "collection": {
"title": "Коллекция", "title": "Коллекция",
@@ -161,7 +181,14 @@
"empty_likes": "Отмечайте треки сердечком — они соберутся здесь", "empty_likes": "Отмечайте треки сердечком — они соберутся здесь",
"empty_dislikes": "Треки с дизлайком не попадут в волну. Пока таких нет", "empty_dislikes": "Треки с дизлайком не попадут в волну. Пока таких нет",
"play_all": "Слушать всё", "play_all": "Слушать всё",
"more": "Показать ещё" "more": "Показать ещё",
"sub_history": "Всё, что вы слушали, по дням. Нажмите на трек, чтобы включить.",
"sub_likes_one": "{{count}} трек · влияют на «Мою волну» сильнее всего",
"sub_likes_few": "{{count}} трека · влияют на «Мою волну» сильнее всего",
"sub_likes_many": "{{count}} треков · влияют на «Мою волну» сильнее всего",
"sub_likes_other": "{{count}} трека · влияют на «Мою волну» сильнее всего",
"sub_dislikes": "Эти треки не попадают в волну и рекомендации. Снимите дизлайк, если передумали.",
"shuffle": "Перемешать"
}, },
"wave": { "wave": {
"title": "Моя волна", "title": "Моя волна",
+354 -233
View File
@@ -1266,6 +1266,11 @@ type ListUsersParams struct {
Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"`
} }
// ListArtistTopTracksParams defines parameters for ListArtistTopTracks.
type ListArtistTopTracksParams struct {
Limit *int `form:"limit,omitempty" json:"limit,omitempty"`
}
// RefreshTokensParams defines parameters for RefreshTokens. // RefreshTokensParams defines parameters for RefreshTokens.
type RefreshTokensParams struct { type RefreshTokensParams struct {
PipuRt *string `form:"pipu_rt,omitempty" json:"pipu_rt,omitempty"` PipuRt *string `form:"pipu_rt,omitempty" json:"pipu_rt,omitempty"`
@@ -1517,6 +1522,9 @@ type ServerInterface interface {
// Альбомы исполнителя (свои и сборники с его треками) // Альбомы исполнителя (свои и сборники с его треками)
// (GET /api/v1/artists/{id}/albums) // (GET /api/v1/artists/{id}/albums)
ListArtistAlbums(w http.ResponseWriter, r *http.Request, id EntityId) ListArtistAlbums(w http.ResponseWriter, r *http.Request, id EntityId)
// Популярные треки исполнителя (по прослушиваниям всех пользователей)
// (GET /api/v1/artists/{id}/top-tracks)
ListArtistTopTracks(w http.ResponseWriter, r *http.Request, id EntityId, params ListArtistTopTracksParams)
// Вход по логину и паролю // Вход по логину и паролю
// (POST /api/v1/auth/login) // (POST /api/v1/auth/login)
Login(w http.ResponseWriter, r *http.Request) Login(w http.ResponseWriter, r *http.Request)
@@ -1835,6 +1843,12 @@ func (_ Unimplemented) ListArtistAlbums(w http.ResponseWriter, r *http.Request,
w.WriteHeader(http.StatusNotImplemented) w.WriteHeader(http.StatusNotImplemented)
} }
// Популярные треки исполнителя (по прослушиваниям всех пользователей)
// (GET /api/v1/artists/{id}/top-tracks)
func (_ Unimplemented) ListArtistTopTracks(w http.ResponseWriter, r *http.Request, id EntityId, params ListArtistTopTracksParams) {
w.WriteHeader(http.StatusNotImplemented)
}
// Вход по логину и паролю // Вход по логину и паролю
// (POST /api/v1/auth/login) // (POST /api/v1/auth/login)
func (_ Unimplemented) Login(w http.ResponseWriter, r *http.Request) { func (_ Unimplemented) Login(w http.ResponseWriter, r *http.Request) {
@@ -3086,6 +3100,48 @@ func (siw *ServerInterfaceWrapper) ListArtistAlbums(w http.ResponseWriter, r *ht
handler.ServeHTTP(w, r) handler.ServeHTTP(w, r)
} }
// ListArtistTopTracks operation middleware
func (siw *ServerInterfaceWrapper) ListArtistTopTracks(w http.ResponseWriter, r *http.Request) {
var err error
// ------------- Path parameter "id" -------------
var id EntityId
err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true})
if err != nil {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err})
return
}
ctx := r.Context()
ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
r = r.WithContext(ctx)
// Parameter object where we will unmarshal all parameters from the context
var params ListArtistTopTracksParams
// ------------- Optional query parameter "limit" -------------
err = runtime.BindQueryParameter("form", true, false, "limit", r.URL.Query(), &params.Limit)
if err != nil {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "limit", Err: err})
return
}
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.ListArtistTopTracks(w, r, id, params)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// Login operation middleware // Login operation middleware
func (siw *ServerInterfaceWrapper) Login(w http.ResponseWriter, r *http.Request) { func (siw *ServerInterfaceWrapper) Login(w http.ResponseWriter, r *http.Request) {
@@ -4399,6 +4455,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Get(options.BaseURL+"/api/v1/artists/{id}/albums", wrapper.ListArtistAlbums) r.Get(options.BaseURL+"/api/v1/artists/{id}/albums", wrapper.ListArtistAlbums)
}) })
r.Group(func(r chi.Router) {
r.Get(options.BaseURL+"/api/v1/artists/{id}/top-tracks", wrapper.ListArtistTopTracks)
})
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Post(options.BaseURL+"/api/v1/auth/login", wrapper.Login) r.Post(options.BaseURL+"/api/v1/auth/login", wrapper.Login)
}) })
@@ -5485,6 +5544,36 @@ func (response ListArtistAlbumsdefaultApplicationProblemPlusJSONResponse) VisitL
return json.NewEncoder(w).Encode(response.Body) return json.NewEncoder(w).Encode(response.Body)
} }
type ListArtistTopTracksRequestObject struct {
Id EntityId `json:"id"`
Params ListArtistTopTracksParams
}
type ListArtistTopTracksResponseObject interface {
VisitListArtistTopTracksResponse(w http.ResponseWriter) error
}
type ListArtistTopTracks200JSONResponse TrackList
func (response ListArtistTopTracks200JSONResponse) VisitListArtistTopTracksResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
return json.NewEncoder(w).Encode(response)
}
type ListArtistTopTracksdefaultApplicationProblemPlusJSONResponse struct {
Body Problem
StatusCode int
}
func (response ListArtistTopTracksdefaultApplicationProblemPlusJSONResponse) VisitListArtistTopTracksResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(response.StatusCode)
return json.NewEncoder(w).Encode(response.Body)
}
type LoginRequestObject struct { type LoginRequestObject struct {
Body *LoginJSONRequestBody Body *LoginJSONRequestBody
} }
@@ -6681,6 +6770,9 @@ type StrictServerInterface interface {
// Альбомы исполнителя (свои и сборники с его треками) // Альбомы исполнителя (свои и сборники с его треками)
// (GET /api/v1/artists/{id}/albums) // (GET /api/v1/artists/{id}/albums)
ListArtistAlbums(ctx context.Context, request ListArtistAlbumsRequestObject) (ListArtistAlbumsResponseObject, error) ListArtistAlbums(ctx context.Context, request ListArtistAlbumsRequestObject) (ListArtistAlbumsResponseObject, error)
// Популярные треки исполнителя (по прослушиваниям всех пользователей)
// (GET /api/v1/artists/{id}/top-tracks)
ListArtistTopTracks(ctx context.Context, request ListArtistTopTracksRequestObject) (ListArtistTopTracksResponseObject, error)
// Вход по логину и паролю // Вход по логину и паролю
// (POST /api/v1/auth/login) // (POST /api/v1/auth/login)
Login(ctx context.Context, request LoginRequestObject) (LoginResponseObject, error) Login(ctx context.Context, request LoginRequestObject) (LoginResponseObject, error)
@@ -7743,6 +7835,33 @@ func (sh *strictHandler) ListArtistAlbums(w http.ResponseWriter, r *http.Request
} }
} }
// ListArtistTopTracks operation middleware
func (sh *strictHandler) ListArtistTopTracks(w http.ResponseWriter, r *http.Request, id EntityId, params ListArtistTopTracksParams) {
var request ListArtistTopTracksRequestObject
request.Id = id
request.Params = params
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
return sh.ssi.ListArtistTopTracks(ctx, request.(ListArtistTopTracksRequestObject))
}
for _, middleware := range sh.middlewares {
handler = middleware(handler, "ListArtistTopTracks")
}
response, err := handler(r.Context(), w, r, request)
if err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
} else if validResponse, ok := response.(ListArtistTopTracksResponseObject); ok {
if err := validResponse.VisitListArtistTopTracksResponse(w); err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
}
} else if response != nil {
sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
}
}
// Login operation middleware // Login operation middleware
func (sh *strictHandler) Login(w http.ResponseWriter, r *http.Request) { func (sh *strictHandler) Login(w http.ResponseWriter, r *http.Request) {
var request LoginRequestObject var request LoginRequestObject
@@ -8792,239 +8911,241 @@ func (sh *strictHandler) GetReadyz(w http.ResponseWriter, r *http.Request) {
// Base64 encoded, gzipped, json marshaled Swagger object // Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{ var swaggerSpec = []string{
"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",
"YSFPodutPb77+P8FAAD//9wKjL6lLQEA",
} }
// GetSwagger returns the content of the embedded swagger specification file // GetSwagger returns the content of the embedded swagger specification file
+9
View File
@@ -132,6 +132,15 @@ func (st *Store) TracksByIDs(ctx context.Context, ids []uuid.UUID) ([]TrackRow,
return out, nil return out, nil
} }
// ArtistTopTracks — треки исполнителя (основной или приглашённый), самые слушаемые всеми пользователями первыми.
func (st *Store) ArtistTopTracks(ctx context.Context, artistID uuid.UUID, limit int) ([]TrackRow, error) {
return st.queryTracks(ctx, `
WHERE t.id IN (SELECT track_id FROM track_artists WHERE artist_id = $1)
ORDER BY COALESCE((SELECT sum(s.play_count) FROM user_track_stats s WHERE s.track_id = t.id), 0) DESC,
t.created_at DESC
LIMIT $2`, artistID, limit)
}
// AlbumTracks — треки альбома по дискам и номерам. // AlbumTracks — треки альбома по дискам и номерам.
func (st *Store) AlbumTracks(ctx context.Context, albumID uuid.UUID) ([]TrackRow, error) { func (st *Store) AlbumTracks(ctx context.Context, albumID uuid.UUID) ([]TrackRow, error) {
return st.queryTracks(ctx, "WHERE t.album_id = $1 ORDER BY t.disc_no NULLS FIRST, t.track_no NULLS LAST, t.title", albumID) return st.queryTracks(ctx, "WHERE t.album_id = $1 ORDER BY t.disc_no NULLS FIRST, t.track_no NULLS LAST, t.title", albumID)
+20
View File
@@ -469,6 +469,26 @@ func (h *Handler) GetAlbum(ctx context.Context, req gen.GetAlbumRequestObject) (
return out, nil return out, nil
} }
// ListArtistTopTracks реализует gen.StrictServerInterface.
func (h *Handler) ListArtistTopTracks(ctx context.Context, req gen.ListArtistTopTracksRequestObject) (gen.ListArtistTopTracksResponseObject, error) {
if _, err := h.store.Artist(ctx, req.Id); err != nil {
return nil, catalogErr(err)
}
limit := 10
if req.Params.Limit != nil {
limit = min(max(*req.Params.Limit, 1), 50)
}
rows, err := h.store.ArtistTopTracks(ctx, req.Id, limit)
if err != nil {
return nil, err
}
items, err := h.Tracks(ctx, rows)
if err != nil {
return nil, err
}
return gen.ListArtistTopTracks200JSONResponse{Items: items}, nil
}
// GetTrack реализует gen.StrictServerInterface. // GetTrack реализует gen.StrictServerInterface.
func (h *Handler) GetTrack(ctx context.Context, req gen.GetTrackRequestObject) (gen.GetTrackResponseObject, error) { func (h *Handler) GetTrack(ctx context.Context, req gen.GetTrackRequestObject) (gen.GetTrackResponseObject, error) {
t, err := h.store.Track(ctx, req.Id) t, err := h.store.Track(ctx, req.Id)