Compare commits

..
3 Commits
Author SHA1 Message Date
mrleo1nidandCursor 72567acd28 feat(web): polish catalog motion, empty states, and mobile now-playing
release / desktop (Linux и Windows в релиз) (push) Successful in 16m22s
release / prune (старые файлы и прогоны) (push) Successful in 6s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m23s
release / release (версия и заметки) (push) Successful in 6s
ci / ts / ts (packages + web) (push) Failing after 5m25s
release / android (APK в релиз) (push) Successful in 4m18s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 09:53:37 +03:00
mrleo1nidandCursor 39aaaed969 feat(web): list previous app builds on the download page
ci / api / api (push) Successful in 48s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 58s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m17s
ci / ts / ts (packages + web) (push) Successful in 2m1s
ci / db / db (push) Successful in 1m4s
GET /app/clients now returns up to 8 Gitea releases per OS so users can download older installers while they still exist after prune.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 08:40:08 +03:00
mrleo1nidandCursor 8eb6b36888 ci(release): keep files and Actions runs for the last 8 releases
ci / ts / ts (packages + web) (push) Successful in 3m30s
release / android (APK в релиз) (push) Successful in 8m57s
release / release (версия и заметки) (push) Successful in 8s
release / desktop (Linux и Windows в релиз) (push) Successful in 16m4s
release / prune (старые файлы и прогоны) (push) Successful in 8s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 08:32:18 +03:00
45 changed files with 1690 additions and 490 deletions

No files matched your search

+17
View File
@@ -2,6 +2,7 @@
# Релиз создаётся сразу (статусы Android/десктоп — «собирается»); сборки докладывают файлы
# и переписывают статус: собран / не удалась (scripts/release.mjs).
# Один Ubuntu-раннер: сначала APK, потом десктоп (Linux native + Windows через cargo-xwin).
# После сборок job prune оставляет файлы у последних 8 релизов и последние 8 прогонов Actions.
# Docker-образы не публикуются: сервер собирает их сам из клонированного репозитория (deploy/update.sh).
#
# Секреты (Settings → Actions → Secrets):
@@ -321,3 +322,19 @@ jobs:
run: |
node scripts/release.mjs desktop linux cancelled
node scripts/release.mjs desktop windows cancelled
prune:
name: prune (старые файлы и прогоны)
needs: [release, android, desktop]
if: always() && needs.release.result == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: "${{ env.NODE_VERSION }}" }
- name: Оставить 8 релизов и 8 прогонов
env:
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
run: node scripts/release.mjs prune
+27 -5
View File
@@ -1547,9 +1547,10 @@ paths:
operationId: getAppClients
tags: [app]
security: [] # публично: страница /download без входа
summary: Клиенты последнего релиза (Android и десктоп)
summary: Клиенты последних релизов (Android и десктоп)
description: |
Тот же релиз Gitea, что и для Android: APK, установщик Windows, .deb, DMG.
У каждой платформы `older` — предыдущие сборки (до 8 релизов, сколько есть с файлами).
Нет настроек Gitea или файлов клиентов — `404 app.update_unavailable`.
responses:
"200":
@@ -3581,12 +3582,12 @@ components:
AppClient:
type: string
enum: [android, windows, linux, macos]
AppClientFile:
AppClientBuild:
type: object
required: [client, filename, size, download_url]
required: [version, filename, size, download_url]
properties:
client:
$ref: "#/components/schemas/AppClient"
version:
type: string
filename:
type: string
size:
@@ -3596,6 +3597,27 @@ components:
type: string
download_url:
type: string
AppClientFile:
type: object
required: [client, filename, size, download_url]
properties:
client:
$ref: "#/components/schemas/AppClient"
version:
type: string
filename:
type: string
size:
type: integer
format: int64
sha256:
type: string
download_url:
type: string
older:
type: array
items:
$ref: "#/components/schemas/AppClientBuild"
AppClients:
type: object
required: [version, version_code, notes_md, clients]
+1 -1
View File
@@ -291,7 +291,7 @@ describe('player', () => {
await user.click(screen.getByRole('button', { name: 'Войти' }));
await screen.findByRole('heading', { name: 'Закрой за мной дверь' });
expect(router.state.location.pathname).toBe(path);
await user.click(screen.getByRole('listitem'));
await user.click(screen.getByRole('button', { name: 'Слушать' }));
const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
await user.click(within(bar).getByRole('button', { name: 'Поделиться треком' }));
expect(await within(bar).findByRole('status')).toHaveTextContent('Ссылка на трек скопирована');
+57 -2
View File
@@ -8,12 +8,18 @@
color: rgba(255, 255, 255, 0.85);
}
.cover img {
.coverImg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
transition: opacity 0.35s ease;
}
.coverImgOn {
opacity: 1;
}
.round {
@@ -59,10 +65,52 @@
width: 128px;
}
.cardCoverWrap {
position: relative;
margin-bottom: 4px;
overflow: hidden;
border-radius: 12px;
}
.cardCover {
aspect-ratio: 1;
border-radius: 12px;
margin-bottom: 4px;
transition: transform 0.28s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.card:hover .cardCover,
.card:focus-within .cardCover {
transform: scale(1.04);
}
.cardPlay {
position: absolute;
right: 10px;
bottom: 10px;
z-index: 1;
display: grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: 50%;
background: #fff;
color: #05070d;
opacity: 0;
transform: translateY(6px);
transition:
opacity 0.18s,
transform 0.18s;
}
.card:hover .cardPlay,
.card:focus-within .cardPlay,
.cardPlay:focus-visible {
opacity: 1;
transform: none;
}
.cardPlay:active {
transform: scale(0.94);
}
.cardTitle {
@@ -104,6 +152,12 @@
min-height: 56px;
padding: 6px 12px;
border-radius: 10px;
animation: pipuIn 0.2s ease-out both;
animation-delay: var(--stagger, 0ms);
}
.trackStatic {
animation: none;
}
.track:has(.trackAlbum) {
@@ -272,6 +326,7 @@
}
@media (max-width: 767px) {
.cardPlay { opacity: 1; transform: none; }
.grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.track, .track:has(.trackAlbum) { gap: 8px; padding: 6px 4px; }
.track:has(.lead), .track:has(.lead):has(.trackAlbum) { grid-template-columns: 24px 40px minmax(0, 1fr) auto; }
+36 -4
View File
@@ -1,10 +1,12 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { Link } from '@tanstack/react-router';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Fragment, useRef, type ReactNode } from 'react';
import { Fragment, useRef, useState, type CSSProperties, type MouseEvent, type ReactNode } from 'react';
import { AddToPlaylist } from './playlists';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { formatDuration } from '../shared/lib/format';
import { usePlayer, usePlayerState } from '../player/context';
import type { PlayContext } from '../player/player';
@@ -59,7 +61,7 @@ export function Cover({
aria-hidden
>
{album.cover_url ? (
<img src={coverSrc(album.cover_url, typeof size === 'number' ? size : 300)} alt="" loading="lazy" />
<CoverImg src={coverSrc(album.cover_url, typeof size === 'number' ? size : 300)} />
) : (
<Icon src={round ? personIcon : albumIcon} size={typeof size === 'number' ? Math.max(16, size * 0.4) : 40} />
)}
@@ -83,10 +85,39 @@ export function ArtistLinks({ artists }: { artists: ArtistRef[] }) {
);
}
function CoverImg({ src }: { src: string }) {
const [ready, setReady] = useState(false);
return (
<img
src={src}
alt=""
loading="lazy"
className={cx(s.coverImg, ready && s.coverImgOn)}
onLoad={() => setReady(true)}
/>
);
}
export function AlbumCard({ album }: { album: AlbumSummary }) {
const { t } = useTranslation();
const session = useSession();
const player = usePlayer();
const play = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
void (async () => {
const full = unwrap(await session.client.GET('/api/v1/albums/{id}', { params: { path: { id: album.id } } }));
if (full.tracks.some((tr) => tr.available)) player.playList(full.tracks, 0, { type: 'album', id: album.id, label: album.title });
})();
};
return (
<Link to="/album/$id" params={{ id: album.id }} className={s.card}>
<Cover album={album} size="100%" className={s.cardCover} />
<span className={s.cardCoverWrap}>
<Cover album={album} size="100%" className={s.cardCover} />
<button type="button" className={s.cardPlay} aria-label={t('catalog.play')} onClick={play}>
<Icon src={playIcon} size={22} />
</button>
</span>
<span className={s.cardTitle}>{album.title}</span>
<span className={s.cardMeta}>
{[album.artist.name, album.year].filter(Boolean).join(' · ')}
@@ -155,7 +186,8 @@ export function TrackList({
role="listitem"
tabIndex={tr.available ? 0 : -1}
aria-current={isCurrent || undefined}
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current)}
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current, virtual && s.trackStatic)}
style={{ '--stagger': `${Math.min(i, 12) * 40}ms` } as CSSProperties}
title={tr.available ? undefined : t('catalog.unavailable')}
onClick={(e) => {
if (e.currentTarget.contains(e.target as Node) && !(e.target as HTMLElement).closest('a, button')) play();
+28 -17
View File
@@ -4,12 +4,12 @@ import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { Cover, TrackList } from '../entities/catalog';
import { Cover, TrackList, coverSrc } from '../entities/catalog';
import { AddToPlaylist } from '../entities/playlists';
import { usePlayer } from '../player/context';
import { formatDuration } from '../shared/lib/format';
import { playIcon, shuffleIcon } from '../shared/ui/icons';
import { Button } from '../shared/ui/ui';
import { Button, PageSkeleton } from '../shared/ui/ui';
import { NotFound } from './NotFound';
import s from './page.module.css';
import c from './catalog.module.css';
@@ -23,30 +23,41 @@ export function AlbumPage({ id }: { id: string }) {
queryFn: async () => unwrap(await session.client.GET('/api/v1/albums/{id}', { params: { path: { id } } })),
});
if (album.isPending) {
return (
<div className={s.page}>
<PageSkeleton />
</div>
);
}
if (album.isError) return <NotFound error={album.error} />;
const a = album.data;
const colors = a?.cover_colors.length === 3 ? a.cover_colors : null;
const ctx = { type: 'album' as const, id, label: a?.title };
if (!a) return null;
const colors = a.cover_colors.length === 3 ? a.cover_colors : null;
const ctx = { type: 'album' as const, id, label: a.title };
return (
<div className={s.page}>
<header className={c.hero}>
{a.cover_url && (
<span className={c.heroBg} aria-hidden>
<img src={coverSrc(a.cover_url, 300)} alt="" />
</span>
)}
{colors && <span className={c.tint} style={{ background: `linear-gradient(180deg, ${colors[0]}66, transparent)` }} aria-hidden />}
<Cover album={a ?? { id }} size={200} />
<Cover album={a} size={200} className={c.heroCover} />
<div className={c.heroBody}>
<span className={c.kind}>{t('catalog.album')}</span>
<h1 className={c.heroTitle}>{a?.title ?? '…'}</h1>
{a && (
<span className={c.heroMeta}>
<Link to="/artist/$id" params={{ id: a.artist.id }}>
{a.artist.name}
</Link>
{a.year ? ` · ${a.year}` : ''} · {t('catalog.track_count', { count: a.tracks.length })} · {formatDuration(a.duration_ms)}
</span>
)}
{a && a.tracks.some((tr) => tr.available) && (
<h1 className={c.heroTitle}>{a.title}</h1>
<span className={c.heroMeta}>
<Link to="/artist/$id" params={{ id: a.artist.id }}>
{a.artist.name}
</Link>
{a.year ? ` · ${a.year}` : ''} · {t('catalog.track_count', { count: a.tracks.length })} · {formatDuration(a.duration_ms)}
</span>
{a.tracks.some((tr) => tr.available) && (
<div className={c.heroActions}>
<AddToPlaylist trackIds={a.tracks.map(tr => tr.id)} />
<AddToPlaylist trackIds={a.tracks.map((tr) => tr.id)} />
<Button variant="primary" className={c.pill} icon={playIcon} onClick={() => player.playList(a.tracks, 0, ctx)}>
{t('catalog.play')}
</Button>
@@ -67,7 +78,7 @@ export function AlbumPage({ id }: { id: string }) {
)}
</div>
</header>
{a && <TrackList tracks={a.tracks} numbered context={ctx} />}
<TrackList tracks={a.tracks} numbered context={ctx} />
</div>
);
}
+35 -9
View File
@@ -12,7 +12,7 @@ import s from './auth.module.css';
/**
* Публичная страница приложений (/download, /android): ссылку можно отправить человеку.
* Сборки — последний релиз Gitea через сервер; первым предлагается клиент текущей ОС.
* Сборки — до 8 последних релизов Gitea через сервер; первым предлагается клиент текущей ОС.
*/
export function AppDownloadPage() {
const { t, i18n } = useTranslation(['auth', 'common']);
@@ -40,10 +40,17 @@ export function AppDownloadPage() {
<span className={s.badge}>{detected ? t(`app.platform.${detected}`) : t('app.badge')}</span>
<h1>{t('app.title')}</h1>
<p className={s.lead}>{t('app.subtitle')}</p>
{latest.isPending ? null : data ? (
{latest.isPending ? (
<p role="status">{t('common:loading')}</p>
) : latest.isError ? (
<div className={s.hint}>{t('app.unavailable')}</div>
) : data ? (
<>
{primary ? (
<DownloadButton client={detected!} href={primary.download_url} meta={`${t('app.version', { version: data.version })} · ${formatBytes(primary.size, i18n.language)}`} />
<>
<DownloadButton client={detected!} href={primary.download_url} meta={`${t('app.version', { version: primary.version ?? data.version })} · ${formatBytes(primary.size, i18n.language)}`} />
<OlderVersions items={primary.older ?? []} />
</>
) : detected ? (
<div className={s.hint}>{t('app.unavailable_platform', { platform: t(`app.platform.${detected}`) })}</div>
) : null}
@@ -72,12 +79,15 @@ export function AppDownloadPage() {
{others.map((id) => {
const file = files[id]!;
return (
<a key={id} className={s.downloadAlt} href={file.download_url} download={file.filename}>
{t(`app.download.${id}`)}
<span>
{t('app.version', { version: data.version })} · {formatBytes(file.size, i18n.language)}
</span>
</a>
<div key={id} className={s.otherBlock}>
<a className={s.downloadAlt} href={file.download_url} download={file.filename}>
{t(`app.download.${id}`)}
<span>
{t('app.version', { version: file.version ?? data.version })} · {formatBytes(file.size, i18n.language)}
</span>
</a>
<OlderVersions items={file.older ?? []} />
</div>
);
})}
</div>
@@ -91,6 +101,22 @@ export function AppDownloadPage() {
);
}
function OlderVersions({ items }: { items: { version: string; filename?: string; size: number; download_url: string }[] }) {
const { t, i18n } = useTranslation(['auth', 'common']);
if (items.length === 0) return null;
return (
<details className={s.older}>
<summary>{t('app.older')}</summary>
{items.map((b) => (
<a key={b.version} className={s.downloadAlt} href={b.download_url} download={b.filename}>
{t('app.version', { version: b.version })}
<span>{formatBytes(b.size, i18n.language)}</span>
</a>
))}
</details>
);
}
function DownloadButton({ client, href, meta }: { client: AppClient; href: string; meta: string }) {
const { t } = useTranslation('auth');
return (
+35 -26
View File
@@ -3,10 +3,10 @@ import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { AlbumGrid, Cover, TrackList } from '../entities/catalog';
import { AlbumGrid, Cover, TrackList, coverSrc } from '../entities/catalog';
import { usePlayer } from '../player/context';
import { playIcon, waveIcon } from '../shared/ui/icons';
import { Button } from '../shared/ui/ui';
import { Button, PageSkeleton } from '../shared/ui/ui';
import { NotFound } from './NotFound';
import s from './page.module.css';
import c from './catalog.module.css';
@@ -29,42 +29,51 @@ export function ArtistPage({ id }: { id: string }) {
queryFn: async () => unwrap(await session.client.GET('/api/v1/artists/{id}/albums', { params: { path: { id } } })).items,
});
if (artist.isPending) {
return (
<div className={s.page}>
<PageSkeleton />
</div>
);
}
if (artist.isError) return <NotFound error={artist.error} />;
const a = artist.data;
if (!a) return null;
const tracks = top.data ?? [];
const ctx = { type: 'artist' as const, id, label: a?.name };
const ctx = { type: 'artist' as const, id, label: a.name };
const own = albums.data?.filter((al) => al.artist.id === id) ?? [];
const appears = albums.data?.filter((al) => al.artist.id !== id) ?? [];
return (
<div className={s.page}>
<header className={c.hero}>
<Cover album={{ id, cover_url: a?.image_url }} size={168} round />
{a.image_url && (
<span className={c.heroBg} aria-hidden>
<img src={coverSrc(a.image_url, 300)} alt="" />
</span>
)}
<Cover album={{ id, cover_url: a.image_url }} size={168} round className={c.heroCover} />
<div className={c.heroBody}>
<span className={c.kind}>{t('catalog.artist')}</span>
<h1 className={c.heroTitle}>{a?.name ?? '…'}</h1>
{a && (
<span className={c.heroMeta}>
{t('catalog.album_count', { count: a.album_count })} · {t('catalog.track_count', { count: a.track_count })}
</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')}
<h1 className={c.heroTitle}>{a.name}</h1>
<span className={c.heroMeta}>
{t('catalog.album_count', { count: a.album_count })} · {t('catalog.track_count', { count: a.track_count })}
</span>
<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>
{tracks.length > 0 && (
<Button className={c.pill} icon={playIcon} onClick={() => player.playList(tracks, 0, ctx)}>
{t('catalog.play')}
</Button>
)}
</div>
)}
)}
</div>
</div>
</header>
{tracks.length > 0 && (
+6 -1
View File
@@ -2,16 +2,21 @@ import type { ReactNode } from 'react';
import { Logo } from '../shared/ui/ui';
import { LanguageSwitch } from '../widgets/LanguageSwitch';
import { WaveButton } from '../widgets/wave/WaveButton';
import s from './auth.module.css';
/** Экран без каркаса: декоративный фон волны и стеклянная карточка по центру. */
/** Экран без каркаса: декоративная волна и стеклянная карточка по центру. */
export function AuthLayout({ children, footer }: { children: ReactNode; footer?: ReactNode }) {
const theme = typeof document !== 'undefined' && document.documentElement.dataset.theme === 'light' ? 'light' : 'dark';
return (
<div className={s.screen}>
<div className={s.backdrop} aria-hidden>
<span className={s.blob1} />
<span className={s.blob2} />
<span className={s.blob3} />
<div className={s.waveDecor}>
<WaveButton interactive={false} size={620} label="" theme={theme} />
</div>
</div>
<header className={s.top}>
<Logo />
+18 -3
View File
@@ -10,7 +10,7 @@ import { useCollectionCounts } from '../entities/collection';
import { usePlayer } from '../player/context';
import { useErrorText } from '../shared/lib/errors';
import { dislikeFillIcon, historyFillIcon, likeFillIcon, playIcon, shuffleIcon } from '../shared/ui/icons';
import { Button, Dialog, FormError, Icon } from '../shared/ui/ui';
import { Button, Dialog, EmptyState, FormError, Icon, PageSkeleton } from '../shared/ui/ui';
import c from './catalog.module.css';
import s from './page.module.css';
@@ -109,7 +109,12 @@ function History() {
</div>
)}
<FormError>{history.error || clear.error ? errorText(history.error ?? clear.error) : null}</FormError>
{history.isSuccess && items.length === 0 && <div className={s.empty}>{t('collection.empty_history')}</div>}
{history.isPending && <PageSkeleton />}
{history.isSuccess && items.length === 0 && (
<EmptyState icon={historyFillIcon} action={<Link to="/">{t('catalog.back_home')}</Link>}>
{t('collection.empty_history')}
</EmptyState>
)}
{groups.map((g) => (
<section key={g.key} className={c.section}>
<h2 className={c.day}>{g.title}</h2>
@@ -189,8 +194,18 @@ function Reacted({ reaction }: { reaction: 'like' | 'dislike' }) {
</div>
)}
<FormError>{list.error ? errorText(list.error) : null}</FormError>
{list.isPending && <PageSkeleton />}
{list.isSuccess && tracks.length === 0 && (
<div className={s.empty}>{t(reaction === 'like' ? 'collection.empty_likes' : 'collection.empty_dislikes')}</div>
<EmptyState
icon={reaction === 'like' ? likeFillIcon : dislikeFillIcon}
action={
<Link to="/search" search={{ q: '' }}>
{t('playlists.find_tracks')}
</Link>
}
>
{t(reaction === 'like' ? 'collection.empty_likes' : 'collection.empty_dislikes')}
</EmptyState>
)}
{tracks.length > 0 && (
<TrackList
+14 -1
View File
@@ -21,7 +21,7 @@ import {
waveSunIcon,
waveWeekendIcon,
} from '../shared/ui/icons';
import { cx, FormError, Icon } from '../shared/ui/ui';
import { cx, FormError, Icon, Skeleton } from '../shared/ui/ui';
import { WaveButton } from '../widgets/wave/WaveButton';
import s from './home.module.css';
import { Onboarding } from './Onboarding';
@@ -54,6 +54,13 @@ export function HomePage() {
queryFn: async () => unwrap(await session.client.GET('/api/v1/onboarding')),
staleTime: Infinity,
});
if (onboarding.isPending) {
return (
<div className={s.home}>
<Skeleton round style={{ width: 320, height: 320, maxWidth: '70vw', maxHeight: '70vw' }} />
</div>
);
}
if (onboarding.data?.required) {
return (
<Onboarding
@@ -136,6 +143,12 @@ function WaveHome() {
{st.error === 'wave.empty' && <p className={s.note}>{t('wave.empty')}</p>}
<FormError>{error ? errorText(error) : null}</FormError>
{options.isPending && (showActivities || showMoods) && (
<div className={s.picker}>
<Skeleton style={{ width: 280, height: 38, borderRadius: 999 }} />
<Skeleton style={{ width: 240, height: 38, borderRadius: 999 }} />
</div>
)}
{options.data && (showActivities || showMoods) && (
<div className={s.picker}>
{showActivities && (
+67 -16
View File
@@ -1,16 +1,17 @@
import { unwrap } from '@pipu/core';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate } from '@tanstack/react-router';
import { useState } from 'react';
import { useState, type MouseEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { Cover, TrackList } from '../entities/catalog';
import { PlaylistEditor, playlistInput, usePlaylist, usePlaylists, type Playlist } from '../entities/playlists';
import type { components } from '@pipu/api-client';
import { usePlayer } from '../player/context';
import { useErrorText } from '../shared/lib/errors';
import { formatDuration } from '../shared/lib/format';
import { Button, Dialog, Field, FormError } from '../shared/ui/ui';
import { playIcon, shuffleIcon } from '../shared/ui/icons';
import { Button, Dialog, EmptyState, Field, FormError, Icon, PageSkeleton } from '../shared/ui/ui';
import { playIcon, playlistAddIcon, searchIcon, shuffleIcon } from '../shared/ui/icons';
import c from './catalog.module.css';
import s from './page.module.css';
import p from './playlists.module.css';
@@ -27,13 +28,16 @@ export function PlaylistsPage() {
<header className={s.toolbar}><h1>{t('playlists.title')}</h1><Button variant="primary" onClick={() => setCreate(true)}>{t('playlists.create')}</Button></header>
<Field label={t('playlists.search')} value={search} onChange={setSearch} type="search" />
<FormError>{list.error ? errorText(list.error) : null}</FormError>
{list.isPending && <p role="status">{t('playlists.loading')}</p>}
{list.isSuccess && !items.length && <p className={s.empty}>{t(search ? 'playlists.no_results' : 'playlists.empty')}</p>}
<div className={p.grid}>{items.map(item => <Link key={item.id} to="/playlist/$id" params={{ id: item.id }} className={p.card}>
<Cover album={item} size="100%" />
<strong>{item.name}</strong><span>{t('catalog.track_count', { count: item.track_count })} · {formatDuration(item.duration_ms)}</span>
{item.description && <span className={p.description}>{item.description}</span>}
</Link>)}</div>
{list.isPending && <PageSkeleton />}
{list.isSuccess && !items.length && (
<EmptyState
icon={search ? searchIcon : playlistAddIcon}
action={!search ? <Button variant="primary" onClick={() => setCreate(true)}>{t('playlists.create')}</Button> : undefined}
>
{t(search ? 'playlists.no_results' : 'playlists.empty')}
</EmptyState>
)}
<div className={p.grid}>{items.map(item => <PlaylistCard key={item.id} item={item} />)}</div>
{create && <PlaylistEditor onClose={() => setCreate(false)} onSaved={item => { setCreate(false); void navigate({ to: '/playlist/$id', params: { id: item.id } }); }} />}
</div>;
}
@@ -45,7 +49,7 @@ export function PlaylistPage({ id }: { id: string }) {
return <div className={s.page}>
<Link to="/collection/playlists">← {t('playlists.title')}</Link>
<FormError>{query.error ? errorText(query.error) : null}</FormError>
{query.isPending && <p role="status">{t('playlists.loading')}</p>}
{query.isPending && <PageSkeleton />}
{query.data && <PlaylistDetail key={id} playlist={query.data} />}
</div>;
}
@@ -93,7 +97,11 @@ function PlaylistDetail({ playlist }: { playlist: Playlist }) {
<Button variant="ghost" disabled={save.isPending} onClick={() => setRemove(true)}>{t('playlists.delete')}</Button>
</div>
<FormError>{save.error ? errorText(save.error) : null}</FormError>
{!playlist.tracks.length && <div className={s.empty}><p>{t('playlists.empty_tracks')}</p><Link to="/search" search={{ q: '' }}>{t('playlists.find_tracks')}</Link></div>}
{!playlist.tracks.length && (
<EmptyState icon={playlistAddIcon} action={<Link to="/search" search={{ q: '' }}>{t('playlists.find_tracks')}</Link>}>
{t('playlists.empty_tracks')}
</EmptyState>
)}
<TrackList tracks={playlist.tracks} showAlbum context={context} lead={(_, i) => String(i + 1)}
actions={manage ? (track, i) => <span className={p.actions}>
<button type="button" disabled={i === 0 || save.isPending} aria-label={t('playlists.move_up', { name: track.title })} onClick={() => move(i, -1)}>↑</button>
@@ -101,9 +109,52 @@ function PlaylistDetail({ playlist }: { playlist: Playlist }) {
<button type="button" disabled={save.isPending} aria-label={t('playlists.remove_track', { name: track.title })} onClick={() => save.mutate(playlist.tracks.filter(x => x.id !== track.id).map(x => x.id))}>×</button>
</span> : undefined} />
{edit && <PlaylistEditor playlist={playlist} onClose={() => setEdit(false)} onSaved={() => setEdit(false)} />}
{remove && <Dialog title={t('playlists.delete')} onClose={() => { if (!deletion.isPending) setRemove(false); }} footer={<>
<Button disabled={deletion.isPending} onClick={() => setRemove(false)}>{t('form.cancel')}</Button>
<Button variant="danger" busy={deletion.isPending} onClick={() => deletion.mutate()}>{t('playlists.delete')}</Button>
</>}><p>{t('playlists.delete_confirm', { name: playlist.name })}</p><FormError>{deletion.error ? errorText(deletion.error) : null}</FormError></Dialog>}
{remove && (
<Dialog
title={t('playlists.delete')}
onClose={() => {
if (!deletion.isPending) setRemove(false);
}}
footer={
<>
<Button disabled={deletion.isPending} onClick={() => setRemove(false)}>{t('form.cancel')}</Button>
<Button variant="danger" busy={deletion.isPending} onClick={() => deletion.mutate()}>{t('playlists.delete')}</Button>
</>
}
>
<p>{t('playlists.delete_confirm', { name: playlist.name })}</p>
<FormError>{deletion.error ? errorText(deletion.error) : null}</FormError>
</Dialog>
)}
</>;
}
function PlaylistCard({ item }: { item: components['schemas']['PlaylistSummary'] }) {
const { t } = useTranslation();
const session = useSession();
const player = usePlayer();
const play = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
void (async () => {
const full = unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id: item.id } } }));
const tracks = full.tracks.filter((x) => x.available);
if (tracks.length) player.playList(tracks, 0, { type: 'collection', id: item.id, label: item.name });
})();
};
return (
<Link to="/playlist/$id" params={{ id: item.id }} className={p.card}>
<span className={p.coverWrap}>
<Cover album={item} size="100%" />
{item.track_count > 0 && (
<button type="button" className={p.play} aria-label={t('catalog.play')} onClick={play}>
<Icon src={playIcon} size={22} />
</button>
)}
</span>
<strong>{item.name}</strong>
<span>{t('catalog.track_count', { count: item.track_count })} · {formatDuration(item.duration_ms)}</span>
{item.description && <span className={p.description}>{item.description}</span>}
</Link>
);
}
+7 -2
View File
@@ -6,7 +6,8 @@ import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { AlbumGrid, ArtistChip, TrackList } from '../entities/catalog';
import { useErrorText } from '../shared/lib/errors';
import { Button, FormError } from '../shared/ui/ui';
import { searchIcon } from '../shared/ui/icons';
import { Button, EmptyState, FormError, PageSkeleton } from '../shared/ui/ui';
import s from './page.module.css';
import c from './catalog.module.css';
@@ -68,7 +69,11 @@ export function SearchPage({ q, type }: { q: string; type: SearchType }) {
</div>
<FormError>{results.error ? errorText(results.error) : null}</FormError>
{empty && <div className={s.empty}>{t('search.nothing')}</div>}
{query === '' && <EmptyState icon={searchIcon}>{t('search.type_query')}</EmptyState>}
{query !== '' && results.isPending && <PageSkeleton />}
{empty && (
<EmptyState icon={searchIcon}>{t('search.nothing')}</EmptyState>
)}
{artists.length > 0 && (
<section className={c.section}>
+53 -13
View File
@@ -4,8 +4,12 @@ import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { ArtistLinks, Cover, TrackList } from '../entities/catalog';
import { ArtistLinks, Cover, coverSrc } from '../entities/catalog';
import { AddToPlaylist } from '../entities/playlists';
import { usePlayer, usePlayerState } from '../player/context';
import { formatDuration } from '../shared/lib/format';
import { pauseIcon, playIcon } from '../shared/ui/icons';
import { Button, PageSkeleton } from '../shared/ui/ui';
import { NotFound } from './NotFound';
import s from './page.module.css';
import c from './catalog.module.css';
@@ -14,27 +18,63 @@ import { TrackGenius } from './TrackGenius';
export function TrackPage({ id }: { id: string }) {
const { t } = useTranslation();
const session = useSession();
const player = usePlayer();
const st = usePlayerState();
const track = useQuery({
queryKey: ['track', id],
queryFn: async () => unwrap(await session.client.GET('/api/v1/tracks/{id}', { params: { path: { id } } })),
});
if (track.isPending) {
return (
<div className={s.page}>
<PageSkeleton />
</div>
);
}
if (track.isError) return <NotFound error={track.error} />;
const tr = track.data;
if (!tr) return null;
const ctx = { type: 'queue' as const };
const current = st.track?.id === tr.id;
const playing = current && st.playing;
return (
<div className={s.page}>
<header className={c.hero}>
{tr && <Cover album={tr.album} size={200} />}
<div className={c.heroBody}>
<span className={c.kind}>{t('player.track_page')}</span>
<h1 className={c.heroTitle}>{tr?.title ?? '…'}</h1>
{tr && <>
<span className={c.heroMeta}><ArtistLinks artists={tr.artists} /> · {formatDuration(tr.duration_ms)}</span>
<Link to="/album/$id" params={{ id: tr.album.id }}>{tr.album.title}</Link>
</>}
<div className={c.listen}>
<header className={c.hero}>
{tr.album.cover_url && (
<span className={c.heroBg} aria-hidden>
<img src={coverSrc(tr.album.cover_url, 300)} alt="" />
</span>
)}
<Cover album={tr.album} size={280} className={c.heroCover} />
<div className={c.heroBody}>
<span className={c.kind}>{t('player.track_page')}</span>
<h1 className={c.heroTitle}>{tr.title}</h1>
<span className={c.heroMeta}>
<ArtistLinks artists={tr.artists} /> · {formatDuration(tr.duration_ms)}
</span>
<Link to="/album/$id" params={{ id: tr.album.id }}>
{tr.album.title}
</Link>
<div className={c.heroActions}>
{tr.available && (
<Button
variant="primary"
className={c.pill}
icon={playing ? pauseIcon : playIcon}
onClick={() => (current ? player.toggle() : player.playList([tr], 0, ctx))}
>
{t(playing ? 'player.pause' : 'catalog.play')}
</Button>
)}
<AddToPlaylist trackIds={[tr.id]} />
</div>
</div>
</header>
<div className={c.listenLyrics}>
<TrackGenius id={id} />
</div>
</header>
{tr && <TrackList tracks={[tr]} context={{ type: 'queue' }} />}
{tr && <TrackGenius id={id} />}
</div>
</div>
);
}
+3 -3
View File
@@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
import { useErrorText } from '../../shared/lib/errors';
import { formatDateTime } from '../../shared/lib/format';
import { Button, FormError } from '../../shared/ui/ui';
import { Button, EmptyState, FormError, PageSkeleton } from '../../shared/ui/ui';
import s from '../page.module.css';
type Entry = components['schemas']['AuditEntry'];
@@ -67,8 +67,8 @@ export function AuditPage() {
))}
</tbody>
</table>
{list.isSuccess && entries.length === 0 && <div className={s.empty}>{t('audit.empty')}</div>}
{list.isPending && <div className={s.empty}>{t('common:loading')}</div>}
{list.isSuccess && entries.length === 0 && <EmptyState>{t('audit.empty')}</EmptyState>}
{list.isPending && <PageSkeleton />}
</div>
{list.hasNextPage && (
<Button className={s.more} busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
+6 -2
View File
@@ -9,7 +9,7 @@ import { useSession, useUser } from '../../app/session';
import { GeniusPanel } from './GeniusPanel';
import { useErrorText, useFieldError } from '../../shared/lib/errors';
import { addIcon, chevronRightIcon, folderIcon, moreIcon, refreshIcon } from '../../shared/ui/icons';
import { Button, Dialog, Field, FormError, Icon, Menu, Segmented, type MenuItem } from '../../shared/ui/ui';
import { Button, Dialog, EmptyState, Field, FormError, Icon, Menu, Segmented, type MenuItem } from '../../shared/ui/ui';
import s from '../page.module.css';
import l from './library.module.css';
import { ScanLine, StatusBadge } from './ScanStatus';
@@ -46,7 +46,11 @@ export function SourcesPage() {
</div>
<FormError>{sources.error ? errorText(sources.error) : null}</FormError>
{user.role === 'admin' && <GeniusPanel />}
{sources.isSuccess && sources.data.length === 0 && <div className={s.empty}>{t('sources.empty')}</div>}
{sources.isSuccess && sources.data.length === 0 && (
<EmptyState icon={folderIcon} action={<Button variant="primary" onClick={() => setEditing('new')}>{t('sources.add')}</Button>}>
{t('sources.empty')}
</EmptyState>
)}
<div className={l.list}>
{sources.data?.map((src) => (
<SourceCard key={src.id} source={src} onEdit={() => setEditing(src)} />
+3 -3
View File
@@ -8,8 +8,8 @@ import { useSession } from '../../app/session';
import { needsFormatAttention } from '../../features/uploads/files';
import { useErrorText, useFieldError } from '../../shared/lib/errors';
import { formatBytes, formatDateTime, formatDuration } from '../../shared/lib/format';
import { checkIcon, closeIcon, editIcon, refreshIcon } from '../../shared/ui/icons';
import { Button, cx, Dialog, Field, FormError, Icon } from '../../shared/ui/ui';
import { checkIcon, closeIcon, editIcon, inboxIcon, refreshIcon } from '../../shared/ui/icons';
import { Button, cx, Dialog, EmptyState, Field, FormError, Icon } from '../../shared/ui/ui';
import s from '../page.module.css';
import { MOVING, QuotaMeter, UploadStatusBadge, useReasonText, type UploadFile, type UploadStatus } from '../uploads/common';
import u from '../uploads/uploads.module.css';
@@ -123,7 +123,7 @@ export function UploadQueuePage() {
{failed.length > 0 && (
<FormError>{t('uploads.review_failed', { count: failed.length, reason: failed[0]?.error ? errorText(problemError(failed[0].error)) : '' })}</FormError>
)}
{list.isSuccess && items.length === 0 && <div className={s.empty}>{t('uploads.queue_empty')}</div>}
{list.isSuccess && items.length === 0 && <EmptyState icon={inboxIcon}>{t('uploads.queue_empty')}</EmptyState>}
{batches.map((files) => (
<BatchCard
+3 -3
View File
@@ -9,7 +9,7 @@ import { useErrorText, useFieldError } from '../../shared/lib/errors';
import { formatRelative } from '../../shared/lib/format';
import { assignableRoles, canManageUser, type Role } from '../../shared/lib/policy';
import { addIcon, checkIcon, copyIcon, lockResetIcon, moreIcon, refreshIcon, searchIcon } from '../../shared/ui/icons';
import { Avatar, Button, cx, Dialog, Field, FormError, Icon, Menu, type MenuItem } from '../../shared/ui/ui';
import { Avatar, Button, cx, Dialog, EmptyState, Field, FormError, Icon, Menu, PageSkeleton, type MenuItem } from '../../shared/ui/ui';
import s from '../page.module.css';
type AdminUser = components['schemas']['AdminUser'];
@@ -232,8 +232,8 @@ export function UsersPage() {
})}
</tbody>
</table>
{list.isSuccess && users.length === 0 && <div className={s.empty}>{t('users.empty')}</div>}
{list.isPending && <div className={s.empty}>{t('common:loading')}</div>}
{list.isSuccess && users.length === 0 && <EmptyState icon={searchIcon}>{t('users.empty')}</EmptyState>}
{list.isPending && <PageSkeleton />}
</div>
{list.hasNextPage && (
<Button className={s.more} busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
+29
View File
@@ -20,6 +20,15 @@
filter: blur(30px);
}
.waveDecor {
position: absolute;
left: 50%;
top: 46%;
transform: translate(-50%, -50%);
opacity: 0.55;
filter: blur(0);
}
.blob1 {
width: 62vmax;
height: 62vmax;
@@ -244,6 +253,12 @@
gap: 8px;
}
.otherBlock {
display: flex;
flex-direction: column;
gap: 8px;
}
.otherTitle {
margin: 8px 0 0;
color: var(--text3);
@@ -272,6 +287,20 @@
font-weight: 500;
}
.older {
font-size: 13px;
color: var(--text3);
}
.older summary {
cursor: pointer;
font-weight: 600;
}
.older .downloadAlt {
margin-top: 8px;
}
.link {
align-self: center;
}
+41 -2
View File
@@ -60,12 +60,23 @@
.heroBg {
position: absolute;
inset: 0;
opacity: 0.35;
inset: -20%;
opacity: 0.4;
filter: blur(60px);
pointer-events: none;
}
.heroBg img {
width: 100%;
height: 100%;
object-fit: cover;
}
.heroCover {
position: relative;
z-index: 1;
}
.tint {
position: absolute;
inset: 0;
@@ -81,12 +92,40 @@
.heroBody {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.listen {
display: grid;
gap: 28px;
}
.listenLyrics {
min-width: 0;
}
@media (min-width: 900px) {
.listen {
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
align-items: start;
}
.listen .hero {
flex-direction: column;
align-items: stretch;
margin: 0;
padding: 0;
}
.listen .heroTitle {
font-size: clamp(28px, 3vw, 40px);
}
}
.kind {
font-size: 12px;
font-weight: 700;
+4
View File
@@ -98,6 +98,10 @@
background: var(--surface3);
}
.chip:active {
transform: scale(0.97);
}
.chipOn,
.chipOn:hover {
background: var(--accent-fill);
+11 -4
View File
@@ -1,9 +1,16 @@
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 24px; margin-top: 24px; }
.card { display: flex; flex-direction: column; gap: 8px; color: inherit; text-decoration: none; min-width: 0; border-radius: 16px; padding: 12px; background: var(--surface, #ffffff08); }
.card > :first-child { aspect-ratio: 1; border-radius: 12px; }
.card { display: flex; flex-direction: column; gap: 8px; color: inherit; text-decoration: none; min-width: 0; border-radius: 16px; padding: 12px; background: var(--surface); animation: pipuIn 0.2s ease-out both; }
.coverWrap { position: relative; aspect-ratio: 1; border-radius: 12px; overflow: hidden; }
.coverWrap > :first-child { width: 100%; height: 100%; }
.card strong { overflow-wrap: anywhere; }
.card span { font-size: 13px; }
.card:hover { background: #ffffff10; }
.card:hover { background: var(--hover); }
.play {
position: absolute; right: 10px; bottom: 10px; z-index: 1; display: grid; place-items: center;
width: 44px; height: 44px; border-radius: 50%; background: #fff; color: #05070d;
opacity: 0; transform: translateY(6px); transition: opacity 0.18s, transform 0.18s;
}
.card:hover .play, .card:focus-within .play, .play:focus-visible { opacity: 1; transform: none; }
.description { white-space: pre-wrap; overflow-wrap: anywhere; }
.card .description { overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.actions { display: flex; align-items: center; }
@@ -11,4 +18,4 @@
.actions button:disabled { opacity: .3; cursor: default; }
.hero { overflow-wrap: anywhere; }
@media (max-width: 600px) { .hero { flex-direction: column; align-items: flex-start; gap: 16px; } .hero > div { width: 100%; } }
@media (max-width: 600px) { .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } .card { padding: 8px; } }
@media (max-width: 600px) { .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } .card { padding: 8px; } .play { opacity: 1; transform: none; } }
+6 -2
View File
@@ -8,7 +8,7 @@ import { useSession } from '../../app/session';
import { useErrorText } from '../../shared/lib/errors';
import { formatBytes, formatDateTime } from '../../shared/lib/format';
import { addIcon } from '../../shared/ui/icons';
import { Button, cx, FormError } from '../../shared/ui/ui';
import { Button, cx, EmptyState, FormError } from '../../shared/ui/ui';
import s from '../page.module.css';
import { MOVING, QuotaMeter, UploadStatusBadge, useReasonText, type UploadFile, type UploadStatus } from './common';
import u from './uploads.module.css';
@@ -86,7 +86,11 @@ export function MyUploadsPage() {
</Link>
</div>
<FormError>{(list.error ?? withdraw.error) ? errorText(list.error ?? withdraw.error) : null}</FormError>
{list.isSuccess && items.length === 0 && <div className={s.empty}>{t('uploads.mine_empty')}</div>}
{list.isSuccess && items.length === 0 && (
<EmptyState icon={addIcon} action={<Link to="/uploads/new">{t('uploads.suggest_title')}</Link>}>
{t('uploads.mine_empty')}
</EmptyState>
)}
{items.length > 0 && (
<div className={s.tableWrap}>
<table className={s.table}>
+115 -1
View File
@@ -41,7 +41,12 @@
transition:
background 0.15s,
box-shadow 0.15s,
opacity 0.15s;
opacity 0.15s,
transform 0.12s;
}
.button:active:not(:disabled) {
transform: scale(0.97);
}
.button:disabled {
@@ -53,6 +58,115 @@
cursor: progress;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: pipuSpin 0.6s linear infinite;
}
@keyframes pipuSpin {
to {
transform: rotate(360deg);
}
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 48px 16px;
text-align: center;
color: var(--text3);
}
.emptyIcon {
color: var(--text3);
opacity: 0.7;
}
.emptyText {
margin: 0;
max-width: 28rem;
font-size: 15px;
line-height: 1.45;
}
.empty a,
.empty button {
color: var(--accent2);
font-weight: 600;
text-decoration: none;
}
.empty a:hover {
text-decoration: underline;
}
.skeleton {
display: block;
background: var(--surface3);
border-radius: 8px;
animation: pipuShimmer 1.2s ease-in-out infinite;
}
.skeletonRound {
border-radius: 50%;
}
@keyframes pipuShimmer {
0%,
100% {
opacity: 0.45;
}
50% {
opacity: 0.9;
}
}
@media (prefers-reduced-motion: reduce) {
.skeleton {
animation: none;
}
}
.pageSkel {
display: flex;
flex-direction: column;
gap: 16px;
}
.skelHero {
display: flex;
align-items: flex-end;
gap: 24px;
margin-bottom: 8px;
}
.skelCover {
width: 160px;
height: 160px;
border-radius: 12px;
flex: none;
}
.skelCopy {
display: flex;
flex-direction: column;
gap: 12px;
flex: 1;
padding-bottom: 8px;
}
.skelRow {
display: flex;
align-items: center;
gap: 14px;
}
.primary {
background: var(--accent-fill);
color: #fff;
+57 -1
View File
@@ -4,6 +4,7 @@ import {
useRef,
useState,
type ButtonHTMLAttributes,
type CSSProperties,
type InputHTMLAttributes,
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
@@ -52,7 +53,7 @@ export function Button({ variant = 'secondary', icon, busy, className, children,
aria-busy={busy || undefined}
className={cx(s.button, s[variant], className)}
>
{icon && <Icon src={icon} size={18} />}
{busy ? <span className={s.spinner} aria-hidden /> : icon ? <Icon src={icon} size={18} /> : null}
{children}
</button>
);
@@ -283,6 +284,61 @@ export function Menu({
);
}
/** Пустой список: иконка, текст и действие (docs/design.md#5). */
export function EmptyState({
icon,
children,
action,
}: {
icon?: string;
children: ReactNode;
action?: ReactNode;
}) {
return (
<div className={s.empty} role="status">
{icon && <Icon src={icon} size={40} className={s.emptyIcon} />}
<p className={s.emptyText}>{children}</p>
{action}
</div>
);
}
export function Skeleton({
className,
style,
round,
}: {
className?: string;
style?: CSSProperties;
round?: boolean;
}) {
return <span className={cx(s.skeleton, round && s.skeletonRound, className)} style={style} aria-hidden />;
}
/** Каркас героя и строк списка, пока страница ещё грузится. */
export function PageSkeleton() {
const { t } = useTranslation();
return (
<div className={s.pageSkel} role="status" aria-busy="true" aria-label={t('loading')}>
<div className={s.skelHero}>
<Skeleton className={s.skelCover} />
<div className={s.skelCopy}>
<Skeleton style={{ width: 72, height: 12 }} />
<Skeleton style={{ width: '55%', height: 28 }} />
<Skeleton style={{ width: '38%', height: 14 }} />
</div>
</div>
{Array.from({ length: 6 }, (_, i) => (
<div key={i} className={s.skelRow}>
<Skeleton style={{ width: 40, height: 40 }} />
<Skeleton style={{ flex: 1, height: 14 }} />
<Skeleton style={{ width: 36, height: 12 }} />
</div>
))}
</div>
);
}
export function Avatar({ name, size = 32 }: { name: string; size?: number }) {
const initials = name
.split(/[\s._-]+/)
+7 -1
View File
@@ -122,7 +122,13 @@ export function fakeApi(initial: FakeUser[]) {
version_code: 1_030_099,
notes_md: '',
clients: [
{ client: 'windows', filename: 'pipu-music_1.3.0_x64-setup.exe', size: 20_000_000, download_url: '/api/v1/app/download/windows/1.3.0' },
{
client: 'windows',
filename: 'pipu-music_1.3.0_x64-setup.exe',
size: 20_000_000,
download_url: '/api/v1/app/download/windows/1.3.0',
older: [{ version: '1.2.0', filename: 'pipu-music_1.2.0_x64-setup.exe', size: 19_000_000, download_url: '/api/v1/app/download/windows/1.2.0' }],
},
{ client: 'linux', filename: 'pipu-music_1.3.0_amd64.deb', size: 4_000_000, download_url: '/api/v1/app/download/linux/1.3.0' },
{ client: 'macos', filename: 'pipu-music_1.3.0_aarch64.dmg', size: 22_000_000, download_url: '/api/v1/app/download/macos/1.3.0' },
{ client: 'android', filename: 'pipu-music-1.3.0.apk', size: 12_000_000, sha256: 'ab'.repeat(32), download_url: '/api/v1/app/download/android/1.3.0' },
+21
View File
@@ -227,12 +227,33 @@
color: var(--accent2);
}
.skip {
position: absolute;
left: 12px;
top: 8px;
z-index: 50;
padding: 8px 14px;
border-radius: 10px;
background: var(--elev);
color: var(--text);
font-weight: 600;
text-decoration: none;
transform: translateY(-120%);
}
.skip:focus {
transform: none;
}
.content {
grid-area: content;
min-width: 0;
min-height: 0;
overflow-y: auto;
padding: 32px 36px;
}
.routeIn {
animation: pipuIn 0.2s ease-out;
}
+19 -7
View File
@@ -1,7 +1,7 @@
import { queueIcon } from '../shared/ui/icons';
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { Link, Outlet, useNavigate } from '@tanstack/react-router';
import { Link, Outlet, useNavigate, useRouterState } from '@tanstack/react-router';
import { Suspense, useCallback, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
@@ -30,7 +30,7 @@ import {
chevronRightIcon,
storageFillIcon,
storageIcon, inboxFillIcon, inboxIcon, myUploadsFillIcon, myUploadsIcon, uploadFillIcon, uploadIcon, tuneFillIcon, tuneIcon} from '../shared/ui/icons';
import { Avatar, cx, Dialog, Icon, Logo, Menu } from '../shared/ui/ui';
import { Avatar, cx, Dialog, Icon, Logo, Menu, PageSkeleton } from '../shared/ui/ui';
import { LanguageSwitch } from './LanguageSwitch';
import { PlayerProvider, usePlayer, usePlayerState } from '../player/context';
import { Ambient } from './Ambient';
@@ -226,6 +226,9 @@ function Shell() {
return (
<div className={cx(s.shell, island && s.island)}>
{user.ui_prefs.ambient !== false && <ShellAmbient />}
<a href="#content" className={s.skip}>
{t('nav.skip')}
</a>
<header className={s.top}>
<div className={s.topLeft}>
<Link to="/" className={s.logoLink}>
@@ -284,17 +287,26 @@ function Shell() {
</Dialog>
)}
<main className={s.content}>
<Suspense fallback={null}>
<Outlet />
</Suspense>
</main>
<ShellMain />
<PlayerBar variant={island ? 'island' : 'bar'} />
</div>
);
}
function ShellMain() {
const key = useRouterState({ select: (st) => st.location.pathname });
return (
<main id="content" className={s.content} tabIndex={-1}>
<div key={key} className={s.routeIn}>
<Suspense fallback={<PageSkeleton />}>
<Outlet />
</Suspense>
</div>
</main>
);
}
/** Третье пятно фона — в цвете обложки текущего трека. */
function ShellAmbient() {
const player = usePlayer();
+115 -2
View File
@@ -149,6 +149,115 @@ a.source:hover {
transform: scale(1.06);
}
.play:active {
transform: scale(0.94);
}
.nowArt {
position: relative;
display: grid;
place-items: center;
flex: none;
padding: 0;
border-radius: 10px;
}
.nowEq {
position: absolute;
right: -2px;
bottom: -2px;
padding: 2px 4px;
border-radius: 6px;
background: var(--surface);
}
.coverLive {
animation: pipuCoverPulse 2.4s ease-in-out infinite;
}
@keyframes pipuCoverPulse {
50% {
filter: brightness(1.12);
}
}
.queueScrim,
.sheetScrim {
position: fixed;
inset: 0;
z-index: 15;
background: var(--overlay);
animation: pipuIn 0.18s ease-out;
}
.sheet {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 16;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
max-height: min(92dvh, 760px);
padding: 12px 20px calc(24px + env(safe-area-inset-bottom, 0px));
border-radius: 24px 24px 0 0;
background: var(--glass);
backdrop-filter: blur(28px) saturate(1.3);
animation: pipuIn 0.22s ease-out;
}
.sheetHead {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
font-weight: 600;
}
.sheetCover {
width: min(240px, 56vw) !important;
height: min(240px, 56vw) !important;
border-radius: 16px;
}
.sheetMeta {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
text-align: center;
min-width: 0;
max-width: 100%;
}
.sheetTitle {
font-family: var(--font-display);
font-size: 22px;
font-weight: 600;
color: inherit;
text-decoration: none;
}
.sheetControls,
.sheetExtra {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
}
.sheet .progress {
max-width: none;
}
.sheet .volume {
flex: 1;
max-width: 160px;
}
.play[aria-busy='true'] {
opacity: 0.7;
}
@@ -218,6 +327,10 @@ a.source:hover {
}
@media (max-width: 767px) {
.now {
cursor: pointer;
}
.bar {
grid-template-columns: minmax(0, 1fr) auto;
padding: 0 12px;
@@ -228,7 +341,7 @@ a.source:hover {
.right,
.controls > :first-child,
.controls > :last-child,
.now > button {
.now > button:not(.nowArt) {
display: none;
}
}
@@ -237,7 +350,7 @@ a.source:hover {
position: fixed;
right: 16px;
bottom: 100px;
z-index: 6;
z-index: 16;
display: flex;
flex-direction: column;
width: min(380px, calc(100% - 32px));
+164 -5
View File
@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom';
import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { ArtistLinks, Cover } from '../entities/catalog';
import { ArtistLinks, Cover, Equalizer } from '../entities/catalog';
import { AddToPlaylist } from '../entities/playlists';
import { usePlayer, usePlayerState } from '../player/context';
import { formatDuration } from '../shared/lib/format';
@@ -37,6 +37,8 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
const { t } = useTranslation();
const player = usePlayer();
const st = usePlayerState();
const narrow = useNarrow();
const [sheetOpen, setSheetOpen] = useState(false);
const [queueOpen, setQueueOpen] = useState(false);
const [shareStatus, setShareStatus] = useState<'copied' | 'error' | null>(null);
const [sharing, setSharing] = useState(false);
@@ -97,8 +99,24 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
return (
<footer className={cx(s.bar, variant === 'island' && s.island)} aria-label={t('player.label')}>
<div className={s.now}>
<Cover album={track.album} size={52} />
<div
className={s.now}
onClick={
narrow
? (e) => {
if (!(e.target as HTMLElement).closest('a, button')) setSheetOpen(true);
}
: undefined
}
>
{narrow ? (
<button type="button" className={s.nowArt} aria-label={t('player.expand')} onClick={() => setSheetOpen(true)}>
<Cover album={track.album} size={52} className={st.playing ? s.coverLive : undefined} />
{st.playing && <span className={s.nowEq}><Equalizer playing /></span>}
</button>
) : (
<Cover album={track.album} size={52} className={st.playing ? s.coverLive : undefined} />
)}
<div className={s.meta}>
<span className={s.title}>{track.title}</span>
<span className={s.artists}>
@@ -232,12 +250,150 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
style={{ '--pct': `${(st.muted ? 0 : st.volume) * 100}%` } as CSSProperties}
/>
</div>
{sheetOpen && narrow && (
<NowPlayingSheet
variant={variant}
onClose={() => setSheetOpen(false)}
share={share}
sharing={sharing}
/>
)}
{queueOpen && <QueueDrawer id={queueId} variant={variant} onClose={closeQueue} />}
{shareStatus && <span className={s.shareStatus} role="status">{t(shareStatus === 'copied' ? 'player.link_copied' : 'player.share_error')}</span>}
</footer>
);
}
function useNarrow() {
const [narrow, setNarrow] = useState(false);
useEffect(() => {
const mq = window.matchMedia?.('(max-width: 767px)');
if (!mq) return;
const sync = () => setNarrow(mq.matches);
sync();
mq.addEventListener?.('change', sync);
return () => mq.removeEventListener?.('change', sync);
}, []);
return narrow;
}
function NowPlayingSheet({
variant,
onClose,
share,
sharing,
}: {
variant: 'bar' | 'island';
onClose: () => void;
share: () => Promise<void>;
sharing: boolean;
}) {
const { t } = useTranslation();
const player = usePlayer();
const st = usePlayerState();
const track = st.track;
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', onKey);
document.body.style.overflow = prev;
};
}, [onClose]);
if (!track) return null;
const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
const repeatLabel = t(`player.repeat_${st.repeat}`);
return createPortal(
<div className={s.sheetScrim} onClick={onClose}>
<div
className={cx(s.sheet, variant === 'island' && s.sheetIsland)}
role="dialog"
aria-modal="true"
aria-label={t('player.now_playing')}
onClick={(e) => e.stopPropagation()}
>
<div className={s.sheetHead}>
<span>{t('player.now_playing')}</span>
<button type="button" className={s.icon} aria-label={t('form.close')} onClick={onClose}>
<Icon src={closeIcon} size={20} />
</button>
</div>
<Cover album={track.album} size={240} className={cx(s.sheetCover, st.playing && s.coverLive)} />
<div className={s.sheetMeta}>
<Link to="/track/$id" params={{ id: track.id }} className={s.sheetTitle} onClick={onClose}>
{track.title}
</Link>
<span className={s.artists}>
<ArtistLinks artists={track.artists} />
</span>
</div>
<div className={s.progress}>
<span className={s.time}>{formatDuration(st.positionMs)}</span>
<input
type="range"
className={s.range}
min={0}
max={st.durationMs || track.duration_ms}
step={1000}
value={Math.min(st.positionMs, st.durationMs || track.duration_ms)}
aria-label={t('player.progress')}
onChange={(e) => player.seek(Number(e.target.value))}
style={{ '--pct': `${(st.positionMs / (st.durationMs || track.duration_ms)) * 100}%` } as CSSProperties}
/>
<span className={s.time}>{formatDuration(st.durationMs || track.duration_ms)}</span>
</div>
<div className={s.sheetControls}>
<button type="button" className={cx(s.icon, track.reaction === 'dislike' && s.danger)} aria-label={t(track.reaction === 'dislike' ? 'player.undislike' : 'player.dislike')} aria-pressed={track.reaction === 'dislike'} onClick={() => react('dislike')}>
<Icon src={track.reaction === 'dislike' ? dislikeFillIcon : dislikeIcon} />
</button>
<button type="button" className={s.icon} aria-label={t('player.prev')} onClick={() => player.prev()}>
<Icon src={prevIcon} size={28} />
</button>
<button type="button" className={s.play} aria-label={t(st.playing ? 'player.pause' : 'player.play')} onClick={() => player.toggle()}>
<Icon src={st.playing ? pauseIcon : playIcon} size={32} />
</button>
<button type="button" className={s.icon} aria-label={t('player.next')} onClick={() => player.next()}>
<Icon src={nextIcon} size={28} />
</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.sheetExtra}>
<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={22} />
</button>
<button type="button" className={cx(s.icon, st.repeat !== 'off' && s.accent)} aria-label={repeatLabel} onClick={() => player.cycleRepeat()}>
<Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={22} />
</button>
<button type="button" className={s.icon} aria-label={t('player.share')} disabled={sharing} onClick={() => void share()}>
<Icon src={shareIcon} size={20} />
</button>
<button type="button" className={s.icon} aria-label={t(st.muted ? 'player.unmute' : 'player.mute')} onClick={() => player.toggleMute()}>
<Icon src={st.muted || st.volume === 0 ? volumeOffIcon : volumeIcon} size={22} />
</button>
<input
type="range"
className={cx(s.range, s.volume)}
min={0}
max={1}
step={0.01}
value={st.muted ? 0 : st.volume}
aria-label={t('player.volume')}
onChange={(e) => player.setVolume(Number(e.target.value))}
style={{ '--pct': `${(st.muted ? 0 : st.volume) * 100}%` } as CSSProperties}
/>
</div>
</div>
</div>,
document.body,
);
}
/** Очередь воспроизведения (выдвижная панель справа): что играет и что дальше, клик — перейти к треку. */
function QueueDrawer({ id, variant, onClose }: { id: string; variant: 'bar' | 'island'; onClose: () => void }) {
const { t } = useTranslation();
@@ -256,7 +412,9 @@ function QueueDrawer({ id, variant, onClose }: { id: string; variant: 'bar' | 'i
}, [onClose]);
// Портал: у плеера backdrop-filter, а он делает fixed-потомков относительными к себе.
return createPortal(
<aside id={id} className={cx(s.drawer, variant === 'island' && s.drawerIsland)} aria-label={t('player.queue')}>
<>
<div className={s.queueScrim} onClick={onClose} />
<aside id={id} className={cx(s.drawer, variant === 'island' && s.drawerIsland)} aria-label={t('player.queue')}>
<div className={s.drawerHead}>
<span>{t('player.queue')}</span>
<button ref={close} type="button" className={s.icon} aria-label={t('form.close')} onClick={onClose}>
@@ -282,7 +440,8 @@ function QueueDrawer({ id, variant, onClose }: { id: string; variant: 'bar' | 'i
))}
</div>
{st.wave && <p className={s.drawerNote}>{t('player.queue_wave')}</p>}
</aside>,
</aside>
</>,
document.body,
);
}
+14 -7
View File
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { Cover } from '../entities/catalog';
import { usePlayer } from '../player/context';
import { formatDuration } from '../shared/lib/format';
import { searchIcon } from '../shared/ui/icons';
import { cx, Icon } from '../shared/ui/ui';
@@ -36,6 +37,7 @@ export function SearchBox({
}) {
const { t } = useTranslation();
const session = useSession();
const player = usePlayer();
const navigate = useNavigate();
const listId = useId();
const inputRef = useRef<HTMLInputElement>(null);
@@ -118,8 +120,7 @@ export function SearchBox({
title: tr.title,
subtitle: `${tr.artists.map((a) => a.name).join(', ')} · ${formatDuration(tr.duration_ms)}`,
cover: tr.album,
// Воспроизведение — M3; пока трек открывает свой альбом.
go: () => void navigate({ to: '/album/$id', params: { id: tr.album.id } }),
go: () => player.playList([tr], 0, { type: 'search', id: debounced, label: debounced }),
});
}
@@ -149,7 +150,8 @@ export function SearchBox({
}
};
const showList = open && debounced !== '' && data !== undefined;
const showPopover = open && debounced !== '';
const showList = showPopover && data !== undefined;
const groups: Item['group'][] = ['artists', 'albums', 'tracks'];
const optionId = (i: number) => `${listId}-${i}`;
@@ -188,12 +190,17 @@ export function SearchBox({
<kbd className={s.kbd} aria-hidden>
/
</kbd>
{showList && (
{showPopover && (
<div className={s.popover}>
{data.corrected_query && (
{showPopover && !showList && (
<div className={s.nothing} role="status">
{t('search.searching')}
</div>
)}
{data?.corrected_query && (
<div className={s.corrected}>{t('search.corrected', { query: data.corrected_query })}</div>
)}
<div id={listId} role="listbox" aria-label={t('search.label')}>
{showList && <div id={listId} role="listbox" aria-label={t('search.label')}>
{items.length === 0 && <div className={s.nothing}>{t('search.nothing')}</div>}
{groups.map((g) => {
const groupItems = items.map((it, i) => ({ it, i })).filter(({ it }) => it.group === g);
@@ -221,7 +228,7 @@ export function SearchBox({
</div>
);
})}
</div>
</div>}
<button
type="button"
className={s.all}
+9
View File
@@ -129,6 +129,15 @@ export function WaveButton(props: WaveButtonProps) {
aria-pressed={interactive ? !!props.playing : undefined}
aria-hidden={interactive ? undefined : true}
onMouseMove={interactive ? onMove : undefined}
onFocus={interactive ? () => (s.hoverT = 1) : undefined}
onBlur={
interactive
? () => {
s.hoverT = 0;
s.pressT = 0;
}
: undefined
}
onMouseEnter={interactive ? () => (s.hoverT = 1) : undefined}
onMouseLeave={
interactive
+3 -1
View File
@@ -104,7 +104,9 @@
Android-приложение обновляется само: при запуске оно сверяется с сервером и предлагает новый APK.
Раздать приложение — ссылка `https://<ваш-домен>/download` (без входа; `/android` перенаправляет сюда):
сборка под систему посетителя, остальные платформы ниже, адрес сервера для копирования и короткая инструкция.
APK, Linux (`.deb`) и Windows (NSIS) прикладываются к релизу Gitea автоматически. Та же страница — внизу экрана входа
APK, Linux (`.deb`) и Windows (NSIS) прикладываются к релизу Gitea автоматически. После каждого релиза CI
оставляет файлы только у последних 8 релизов и последние 8 прогонов Actions (теги и заметки не удаляет).
Та же страница — внизу экрана входа
и в меню профиля.
## 7. Наблюдение
+2 -2
View File
@@ -106,8 +106,8 @@
### Мобильное обновление — [mobile.md](mobile.md#самообновление)
| Метод | Путь | Описание |
|---|---|---|
| GET | `/app/clients` | `{version, version_code, notes_md, clients:[{client, filename, size, sha256?, download_url}]}` — Android, Windows, Linux, macOS |
| GET | `/app/download/{client}/{version}` | файл клиента (прокси / кэш релиза Gitea) |
| GET | `/app/clients` | `{version, version_code, notes_md, clients:[{client, filename, size, sha256?, download_url, older:[{version, filename, size, download_url}]}]}` — до 8 релизов на платформу |
| GET | `/app/download/{client}/{version}` | файл клиента этой версии (прокси / кэш релиза Gitea) |
| GET | `/app/android/latest` | `{version, version_code, notes_md, size, sha256, download_url, min_supported}` |
| GET | `/app/android/download/{version}` | APK (прокси / кэш релиза Gitea) |
+2 -2
View File
@@ -99,13 +99,13 @@
## Самообновление
Первая установка — публичная страница `/download` веб-клиента (`/android` ведёт туда же): APK, десктопные сборки по ОС, адрес сервера и инструкция.
Первая установка — публичная страница `/download` веб-клиента (`/android` ведёт туда же): APK, десктопные сборки по ОС, предыдущие версии (до 8 последних релизов, сколько есть), адрес сервера и инструкция.
Приложение распространяется только через Gitea Releases (не Google Play).
**Поток:**
1. При запуске (не чаще раза в 6 ч) и вручную в профиле: `GET /api/v1/app/android/latest` на **сервере Pipu**.
Сервер сам опрашивает Gitea API (`GET /api/v1/repos/{owner}/{repo}/releases/latest`) с серверным токеном,
Сервер сам опрашивает Gitea API (список релизов, до 8 с файлами; fallback — `/releases/latest`) с серверным токеном,
кэширует ответ на 10 мин и APK на диске. Так:
- мобильному приложению не нужен доступ к Gitea и токен (репозиторий может быть приватным);
- версия приложения согласована с версией сервера (`min_supported` из `server-info`).
+14 -3
View File
@@ -1335,8 +1335,9 @@ export interface paths {
cookie?: never;
};
/**
* Клиенты последнего релиза (Android и десктоп)
* Клиенты последних релизов (Android и десктоп)
* @description Тот же релиз Gitea, что и для Android: APK, установщик Windows, .deb, DMG.
* У каждой платформы `older` — предыдущие сборки (до 8 релизов, сколько есть с файлами).
* Нет настроек Gitea или файлов клиентов — `404 app.update_unavailable`.
*/
get: operations["getAppClients"];
@@ -2356,14 +2357,24 @@ export interface components {
};
/** @enum {string} */
AppClient: "android" | "windows" | "linux" | "macos";
AppClientFile: {
client: components["schemas"]["AppClient"];
AppClientBuild: {
version: string;
filename: string;
/** Format: int64 */
size: number;
sha256?: string;
download_url: string;
};
AppClientFile: {
client: components["schemas"]["AppClient"];
version?: string;
filename: string;
/** Format: int64 */
size: number;
sha256?: string;
download_url: string;
older?: components["schemas"]["AppClientBuild"][];
};
AppClients: {
version: string;
version_code: number;
+1
View File
@@ -40,6 +40,7 @@
},
"version": "version {{version}}",
"other": "Other platforms",
"older": "Previous versions",
"step_install": {
"android": "Open the downloaded file. If Android asks, allow installs from this source.",
"windows": "Run the installer and follow the steps. pipu will appear in the Start menu.",
+6 -1
View File
@@ -27,6 +27,7 @@
"en": "English"
},
"nav": {
"skip": "Skip to content",
"more": "More",
"main": "Navigation",
"home": "Home",
@@ -96,7 +97,9 @@
"source_queue": "Queue",
"queue": "Queue",
"source_wave_seed": "Artist wave · {{label}}",
"queue_wave": "The wave never ends: next tracks are picked as you listen"
"queue_wave": "The wave never ends: next tracks are picked as you listen",
"now_playing": "Now playing",
"expand": "Open player"
},
"settings": {
"title": "Settings",
@@ -161,6 +164,8 @@
"tracks": "Tracks",
"all": "All",
"nothing": "Nothing found",
"searching": "Searching…",
"type_query": "Type a query in the search field above",
"corrected": "Showing results for “{{query}}”",
"show_all": "All results",
"title": "Search: {{query}}",
+1
View File
@@ -40,6 +40,7 @@
},
"version": "версия {{version}}",
"other": "Другие платформы",
"older": "Предыдущие версии",
"step_install": {
"android": "Откройте скачанный файл. Если Android спросит — разрешите установку из этого источника.",
"windows": "Запустите установщик и следуйте шагам. pipu появится в меню Пуск.",
+6 -1
View File
@@ -27,6 +27,7 @@
"en": "English"
},
"nav": {
"skip": "К содержимому",
"more": "Ещё",
"main": "Навигация",
"home": "Главная",
@@ -96,7 +97,9 @@
"source_queue": "Очередь",
"queue": "Очередь",
"source_wave_seed": "Волна по исполнителю · {{label}}",
"queue_wave": "Волна бесконечна: следующие треки подбираются по ходу прослушивания"
"queue_wave": "Волна бесконечна: следующие треки подбираются по ходу прослушивания",
"now_playing": "Сейчас играет",
"expand": "Открыть плеер"
},
"settings": {
"title": "Настройки",
@@ -161,6 +164,8 @@
"tracks": "Треки",
"all": "Всё",
"nothing": "Ничего не нашли",
"searching": "Ищем…",
"type_query": "Введите запрос в поиске сверху",
"corrected": "Показаны результаты для «{{query}}»",
"show_all": "Все результаты",
"title": "Поиск: {{query}}",
+71 -1
View File
@@ -4,6 +4,7 @@
// node scripts/release.mjs create — создать релиз по тегу (при перезапуске — обновить)
// node scripts/release.mjs android building|failure|cancelled — статус сборки APK в тексте релиза
// node scripts/release.mjs android success <file.apk> — приложить APK и .sha256, статус «собран»
// node scripts/release.mjs prune — файлы только у последних 8 релизов, старые прогоны Actions
// Окружение: GITEA_API (<server>/api/v1/repos/<owner>/<repo>), GITEA_TOKEN, TAG, VERSION, VERSION_CODE,
// PRERELEASE (true|false), GITHUB_RUN_ID; для success — SIGNING (release|debug).
// PUBLIC_URL — внешний адрес Gitea для ссылок в тексте (job ходит в API по внутреннему адресу, и html_url
@@ -30,6 +31,10 @@ const COMMIT_RE = /^(\w+)(?:\(([^)]+)\))?(!)?:\s*(.+)$/;
export const STATUS_START = '<!-- android-status -->';
export const STATUS_END = '<!-- /android-status -->';
export const DESKTOP_PLATFORMS = ['linux', 'windows'];
/** Сколько последних опубликованных релизов оставляем с файлами (APK, .deb, NSIS). */
export const KEEP_RELEASE_FILES = 8;
/** Сколько последних прогонов Actions оставляем (логи/статусы). */
export const KEEP_ACTION_RUNS = 8;
export function desktopStatusMarks(platform) {
if (!DESKTOP_PLATFORMS.includes(platform)) throw new Error(`unknown desktop platform: ${platform}`);
@@ -194,6 +199,35 @@ export function renderNotes({
return out.join('\n');
}
function publishedAt(item) {
return Date.parse(item.published_at || item.created_at || item.started_at || 0);
}
/** Релизы, с которых снимаем вложения: черновики не трогаем, текущий тег всегда оставляем. */
export function staleReleases(releases, { keep = KEEP_RELEASE_FILES, currentTag } = {}) {
const published = releases.filter((r) => !r.draft);
published.sort((a, b) => publishedAt(b) - publishedAt(a));
const keepIds = new Set(published.slice(0, keep).map((r) => r.id));
const current = published.find((r) => r.tag_name === currentTag);
if (current) keepIds.add(current.id);
return published.filter((r) => !keepIds.has(r.id) && (r.assets?.length ?? 0) > 0);
}
/** Завершённые прогоны Actions, которые можно удалить (текущий прогон не трогаем). */
export function staleActionRuns(runs, { keep = KEEP_ACTION_RUNS, currentRunId } = {}) {
const done = runs.filter((r) => r.status === 'completed' && String(r.id) !== String(currentRunId ?? ''));
done.sort((a, b) => publishedAt(b) - publishedAt(a));
const keepIds = new Set(done.slice(0, keep).map((r) => r.id));
return done.filter((r) => !keepIds.has(r.id));
}
function actionRunsFrom(body) {
if (Array.isArray(body)) return body;
if (Array.isArray(body?.workflow_runs)) return body.workflow_runs;
if (Array.isArray(body?.runs)) return body.runs;
return [];
}
// ---------------------------------------------------------------------------------------------------------
// CLI
@@ -270,6 +304,41 @@ async function create() {
console.log(`${release ? 'обновлён' : 'создан'} релиз ${saved.html_url}`);
}
async function listPages(path, itemsOf) {
const all = [];
for (let page = 1; page <= 40; page += 1) {
const sep = path.includes('?') ? '&' : '?';
const body = await api('GET', `${path}${sep}limit=50&page=${page}`);
const batch = itemsOf(body);
if (!batch.length) break;
all.push(...batch);
if (batch.length < 50) break;
}
return all;
}
async function prune() {
const tag = process.env.TAG;
const currentRunId = process.env.GITHUB_RUN_ID;
const releases = await listPages('/releases', (body) => (Array.isArray(body) ? body : []));
let files = 0;
for (const rel of staleReleases(releases, { currentTag: tag })) {
for (const a of rel.assets ?? []) {
await api('DELETE', `/releases/${rel.id}/assets/${a.id}`);
files += 1;
console.log(`удалён файл ${rel.tag_name}/${a.name}`);
}
}
const runs = await listPages('/actions/runs', actionRunsFrom);
let statuses = 0;
for (const run of staleActionRuns(runs, { currentRunId })) {
await api('DELETE', `/actions/runs/${run.id}`);
statuses += 1;
console.log(`удалён прогон Actions ${run.id} (${run.path ?? run.name ?? run.event ?? ''})`);
}
console.log(`очистка: файлов ${files}, прогонов ${statuses}`);
}
async function upload(release, file) {
const name = basename(file);
for (const old of release.assets ?? []) {
@@ -331,8 +400,9 @@ async function main() {
if (cmd === 'desktop' && DESKTOP_PLATFORMS.includes(args[0]) && ['building', 'success', 'failure', 'cancelled'].includes(args[1])) {
return desktop(args[0], args[1], args.slice(2));
}
if (cmd === 'prune') return prune();
throw new Error(
'использование: release.mjs create | android building|failure|cancelled | android success <file.apk> | desktop linux|windows building|failure|cancelled | desktop linux|windows success <file...>',
'использование: release.mjs create | android building|failure|cancelled | android success <file.apk> | desktop linux|windows building|failure|cancelled | desktop linux|windows success <file...> | prune',
);
}
+35
View File
@@ -9,6 +9,8 @@ import {
replaceAndroidStatus,
replaceDesktopStatus,
desktopStatusMarks,
staleActionRuns,
staleReleases,
STATUS_END,
STATUS_START,
} from './release.mjs';
@@ -131,6 +133,39 @@ test('release notes', () => {
assert.doesNotMatch(notes, /Предварительная версия/);
});
test('keeps files on the newest eight releases', () => {
const releases = Array.from({ length: 10 }, (_, i) => ({
id: i + 1,
tag_name: `v1.0.${i}`,
draft: false,
published_at: `2026-01-${String(i + 1).padStart(2, '0')}T00:00:00Z`,
assets: i === 0 ? [] : [{ id: 100 + i, name: `a${i}.apk` }],
}));
const stale = staleReleases(releases, { keep: 8, currentTag: 'v1.0.9' });
assert.deepEqual(
stale.map((r) => r.tag_name).sort(),
['v1.0.1'],
);
});
test('does not delete the current Actions run or unfinished jobs', () => {
const runs = [
{ id: 20, status: 'completed', created_at: '2026-01-20T00:00:00Z' },
{ id: 19, status: 'in_progress', created_at: '2026-01-19T00:00:00Z' },
...Array.from({ length: 12 }, (_, i) => ({
id: i + 1,
status: 'completed',
created_at: `2026-01-${String(i + 1).padStart(2, '0')}T00:00:00Z`,
})),
];
const stale = staleActionRuns(runs, { keep: 8, currentRunId: 20 });
assert.equal(stale.some((r) => r.id === 20 || r.id === 19), false);
assert.deepEqual(
stale.map((r) => r.id).sort((a, b) => a - b),
[1, 2, 3, 4],
);
});
test('release notes for a first prerelease without changes', () => {
const notes = renderNotes({
version: '0.1.0-rc.1',
+299 -286
View File
@@ -337,13 +337,24 @@ type AndroidRelease struct {
// AppClient defines model for AppClient.
type AppClient string
// AppClientBuild defines model for AppClientBuild.
type AppClientBuild struct {
DownloadUrl string `json:"download_url"`
Filename string `json:"filename"`
Sha256 *string `json:"sha256,omitempty"`
Size int64 `json:"size"`
Version string `json:"version"`
}
// AppClientFile defines model for AppClientFile.
type AppClientFile struct {
Client AppClient `json:"client"`
DownloadUrl string `json:"download_url"`
Filename string `json:"filename"`
Sha256 *string `json:"sha256,omitempty"`
Size int64 `json:"size"`
Client AppClient `json:"client"`
DownloadUrl string `json:"download_url"`
Filename string `json:"filename"`
Older *[]AppClientBuild `json:"older,omitempty"`
Sha256 *string `json:"sha256,omitempty"`
Size int64 `json:"size"`
Version *string `json:"version,omitempty"`
}
// AppClients defines model for AppClients.
@@ -1778,7 +1789,7 @@ type ServerInterface interface {
// Последняя версия Android-приложения (из релизов Gitea через сервер)
// (GET /api/v1/app/android/latest)
GetAndroidLatest(w http.ResponseWriter, r *http.Request)
// Клиенты последнего релиза (Android и десктоп)
// Клиенты последних релизов (Android и десктоп)
// (GET /api/v1/app/clients)
GetAppClients(w http.ResponseWriter, r *http.Request)
// Файл клиента (кэшируется на сервере, поддерживает Range)
@@ -2163,7 +2174,7 @@ func (_ Unimplemented) GetAndroidLatest(w http.ResponseWriter, r *http.Request)
w.WriteHeader(http.StatusNotImplemented)
}
// Клиенты последнего релиза (Android и десктоп)
// Клиенты последних релизов (Android и десктоп)
// (GET /api/v1/app/clients)
func (_ Unimplemented) GetAppClients(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotImplemented)
@@ -8200,7 +8211,7 @@ type StrictServerInterface interface {
// Последняя версия Android-приложения (из релизов Gitea через сервер)
// (GET /api/v1/app/android/latest)
GetAndroidLatest(ctx context.Context, request GetAndroidLatestRequestObject) (GetAndroidLatestResponseObject, error)
// Клиенты последнего релиза (Android и десктоп)
// Клиенты последних релизов (Android и десктоп)
// (GET /api/v1/app/clients)
GetAppClients(ctx context.Context, request GetAppClientsRequestObject) (GetAppClientsResponseObject, error)
// Файл клиента (кэшируется на сервере, поддерживает Range)
@@ -10868,284 +10879,286 @@ func (sh *strictHandler) GetReadyz(w http.ResponseWriter, r *http.Request) {
// Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{
"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",
"hIU8hV6vce/Wvf8bAAD//7F7c4hPbwEA",
}
// GetSwagger returns the content of the embedded swagger specification file
+184 -45
View File
@@ -15,6 +15,7 @@ import (
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
@@ -30,6 +31,9 @@ const CodeUnavailable = "app.update_unavailable"
const cacheTTL = 10 * time.Minute
// keepClientReleases — сколько последних релизов с файлами отдаём на /download (как prune в CI).
const keepClientReleases = 8
var tagRe = regexp.MustCompile(`^v?(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?$`)
// VersionCode — versionCode по тегу; та же формула, что в scripts/version.mjs:
@@ -68,9 +72,12 @@ type asset struct {
}
type release struct {
Tag string `json:"tag_name"`
Body string `json:"body"`
Assets []asset `json:"assets"`
Tag string `json:"tag_name"`
Body string `json:"body"`
Draft bool `json:"draft"`
PublishedAt time.Time `json:"published_at"`
CreatedAt time.Time `json:"created_at"`
Assets []asset `json:"assets"`
}
type clientFile struct {
@@ -98,11 +105,16 @@ type Handler struct {
log *slog.Logger
mu sync.Mutex
cached *Latest
cached *catalog
cachedAt time.Time
dl sync.Mutex
}
type catalog struct {
latest *Latest
all []*Latest
}
// NewHandler создаёт обработчик.
func NewHandler(cfg Config, log *slog.Logger) *Handler {
return &Handler{cfg: cfg, client: &http.Client{Timeout: 10 * time.Minute}, log: log}
@@ -196,7 +208,7 @@ func pickClientFiles(assets []asset) map[string]clientFile {
return out
}
func (h *Handler) latest(ctx context.Context) (*Latest, error) {
func (h *Handler) catalog(ctx context.Context) (*catalog, error) {
if !h.configured() {
return nil, apperr.NotFound(CodeUnavailable, "gitea is not configured")
}
@@ -208,7 +220,8 @@ func (h *Handler) latest(ctx context.Context) (*Latest, error) {
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
base := strings.TrimRight(h.cfg.GiteaURL, "/")
res, err := h.get(ctx, base+"/api/v1/repos/"+h.cfg.Repo+"/releases/latest")
repo := h.cfg.Repo
rels, err := h.listReleases(ctx, base, repo)
if err != nil {
h.log.WarnContext(ctx, "app update: gitea unavailable", "err", err)
if h.cached != nil {
@@ -216,37 +229,114 @@ func (h *Handler) latest(ctx context.Context) (*Latest, error) {
}
return nil, apperr.NotFound(CodeUnavailable, "release is unavailable")
}
var rel release
err = json.NewDecoder(res.Body).Decode(&rel)
_ = res.Body.Close()
if err != nil {
return nil, fmt.Errorf("gitea: decode release: %w", err)
var all []*Latest
for _, rel := range rels {
if rel.Draft {
continue
}
l := h.parseRelease(ctx, rel)
if l == nil {
continue
}
all = append(all, l)
if len(all) >= keepClientReleases {
break
}
}
if len(all) == 0 {
return nil, apperr.NotFound(CodeUnavailable, "release has no client builds")
}
cat := &catalog{latest: all[0], all: all}
h.cached = cat
h.cachedAt = time.Now()
return cat, nil
}
func (h *Handler) listReleases(ctx context.Context, base, repo string) ([]release, error) {
res, err := h.get(ctx, base+"/api/v1/repos/"+repo+"/releases?limit=20")
if err == nil {
var rels []release
decErr := json.NewDecoder(res.Body).Decode(&rels)
_ = res.Body.Close()
if decErr != nil {
return nil, fmt.Errorf("gitea: decode releases: %w", decErr)
}
sort.SliceStable(rels, func(i, j int) bool {
ti, tj := rels[i].PublishedAt, rels[j].PublishedAt
if ti.IsZero() {
ti = rels[i].CreatedAt
}
if tj.IsZero() {
tj = rels[j].CreatedAt
}
return ti.After(tj)
})
return rels, nil
}
res, err = h.get(ctx, base+"/api/v1/repos/"+repo+"/releases/latest")
if err != nil {
return nil, err
}
var rel release
decErr := json.NewDecoder(res.Body).Decode(&rel)
_ = res.Body.Close()
if decErr != nil {
return nil, fmt.Errorf("gitea: decode release: %w", decErr)
}
return []release{rel}, nil
}
func (h *Handler) parseRelease(ctx context.Context, rel release) *Latest {
version, code, err := VersionCode(rel.Tag)
if err != nil {
return nil, apperr.NotFound(CodeUnavailable, err.Error())
return nil
}
files := pickClientFiles(rel.Assets)
if len(files) == 0 {
return nil, apperr.NotFound(CodeUnavailable, "release has no client builds")
return nil
}
if apk, ok := files["android"]; ok {
if apk.ChecksumURL == "" {
delete(files, "android")
} else if digest, err := h.readDigest(ctx, apk.ChecksumURL); err != nil {
h.log.WarnContext(ctx, "app update: apk checksum unavailable", "err", err)
h.log.WarnContext(ctx, "app update: apk checksum unavailable", "err", err, "version", version)
delete(files, "android")
} else {
apk.SHA256 = digest
files["android"] = apk
}
if len(files) == 0 {
return nil, apperr.NotFound(CodeUnavailable, "release has no APK yet")
}
if len(files) == 0 {
return nil
}
return &Latest{Version: version, VersionCode: code, Notes: rel.Body, Files: files}
}
func (h *Handler) latest(ctx context.Context) (*Latest, error) {
cat, err := h.catalog(ctx)
if err != nil {
return nil, err
}
return cat.latest, nil
}
func (c *catalog) release(version string) *Latest {
for _, l := range c.all {
if l.Version == version {
return l
}
}
h.cached = &Latest{Version: version, VersionCode: code, Notes: rel.Body, Files: files}
h.cachedAt = time.Now()
return h.cached, nil
return nil
}
func (c *catalog) keepNames() map[string]struct{} {
out := map[string]struct{}{}
for _, l := range c.all {
for _, f := range l.Files {
out[f.Name] = struct{}{}
}
}
return out
}
func (h *Handler) readDigest(ctx context.Context, url string) (string, error) {
@@ -272,33 +362,73 @@ func (h *Handler) fileURL(kind, version string) string {
// GetAppClients реализует gen.StrictServerInterface.
func (h *Handler) GetAppClients(ctx context.Context, _ gen.GetAppClientsRequestObject) (gen.GetAppClientsResponseObject, error) {
l, err := h.latest(ctx)
cat, err := h.catalog(ctx)
if err != nil {
return nil, err
}
l := cat.latest
order := []string{"windows", "macos", "linux", "android"}
clients := make([]gen.AppClientFile, 0, len(l.Files))
clients := make([]gen.AppClientFile, 0, len(order))
for _, kind := range order {
f, ok := l.Files[kind]
item, ok := h.clientWithOlder(cat, kind)
if !ok {
continue
}
item := gen.AppClientFile{
Client: gen.AppClient(kind),
Filename: f.Name,
Size: f.Size,
DownloadUrl: h.fileURL(kind, l.Version),
}
if f.SHA256 != "" {
item.Sha256 = &f.SHA256
}
clients = append(clients, item)
}
if len(clients) == 0 {
return nil, apperr.NotFound(CodeUnavailable, "release has no client builds")
}
return gen.GetAppClients200JSONResponse{
Version: l.Version, VersionCode: l.VersionCode, NotesMd: l.Notes, Clients: clients,
}, nil
}
func (h *Handler) clientWithOlder(cat *catalog, kind string) (gen.AppClientFile, bool) {
var head *Latest
var file clientFile
var older []gen.AppClientBuild
for _, rel := range cat.all {
f, ok := rel.Files[kind]
if !ok {
continue
}
if head == nil {
head, file = rel, f
continue
}
older = append(older, gen.AppClientBuild{
Version: rel.Version,
Filename: f.Name,
Size: f.Size,
DownloadUrl: h.fileURL(kind, rel.Version),
Sha256: shaPtr(f.SHA256),
})
}
if head == nil {
return gen.AppClientFile{}, false
}
item := gen.AppClientFile{
Client: gen.AppClient(kind),
Version: &head.Version,
Filename: file.Name,
Size: file.Size,
DownloadUrl: h.fileURL(kind, head.Version),
Sha256: shaPtr(file.SHA256),
}
if len(older) > 0 {
item.Older = &older
}
return item, true
}
func shaPtr(s string) *string {
if s == "" {
return nil
}
return &s
}
// GetAndroidLatest реализует gen.StrictServerInterface.
func (h *Handler) GetAndroidLatest(ctx context.Context, _ gen.GetAndroidLatestRequestObject) (gen.GetAndroidLatestResponseObject, error) {
l, err := h.latest(ctx)
@@ -359,16 +489,20 @@ func (h *Handler) ensure(ctx context.Context, f clientFile) (string, error) {
}
func (h *Handler) cleanup(keepName string) {
keep := map[string]struct{}{keepName: {}}
h.mu.Lock()
if h.cached != nil {
for name := range h.cached.keepNames() {
keep[name] = struct{}{}
}
}
h.mu.Unlock()
matches, _ := filepath.Glob(filepath.Join(h.cfg.Dir, "pipu-music-*"))
ver := versionFromName(keepName)
for _, m := range matches {
base := filepath.Base(m)
if ver != "" && strings.Contains(base, ver) {
if _, ok := keep[filepath.Base(m)]; ok {
continue
}
if base != keepName {
_ = os.Remove(m)
}
_ = os.Remove(m)
}
}
@@ -382,7 +516,7 @@ func versionFromName(name string) string {
func (h *Handler) serveFile(ctx context.Context, l *Latest, kind string) (fileResponse, error) {
f, ok := l.Files[kind]
if !ok || (kind == "android" && f.SHA256 == "") {
if !ok {
return fileResponse{}, apperr.NotFound(CodeUnavailable, "client build is unavailable")
}
path, err := h.ensure(ctx, f)
@@ -393,26 +527,31 @@ func (h *Handler) serveFile(ctx context.Context, l *Latest, kind string) (fileRe
return fileResponse{path: path, name: f.Name, req: httpx.RequestFrom(ctx)}, nil
}
// DownloadAndroidApk реализует gen.StrictServerInterface. Отдаётся только последняя версия.
// DownloadAndroidApk реализует gen.StrictServerInterface. Версия должна быть в каталоге (до 8 релизов).
func (h *Handler) DownloadAndroidApk(ctx context.Context, req gen.DownloadAndroidApkRequestObject) (gen.DownloadAndroidApkResponseObject, error) {
l, err := h.latest(ctx)
cat, err := h.catalog(ctx)
if err != nil {
return nil, err
}
if req.Version != l.Version {
return nil, apperr.NotFound(CodeUnavailable, "only the latest version is available")
l := cat.release(req.Version)
if l == nil {
return nil, apperr.NotFound(CodeUnavailable, "this version is no longer available")
}
if _, ok := l.android(); !ok {
return nil, apperr.NotFound(CodeUnavailable, "client build is unavailable")
}
return h.serveFile(ctx, l, "android")
}
// DownloadAppClient реализует gen.StrictServerInterface.
func (h *Handler) DownloadAppClient(ctx context.Context, req gen.DownloadAppClientRequestObject) (gen.DownloadAppClientResponseObject, error) {
l, err := h.latest(ctx)
cat, err := h.catalog(ctx)
if err != nil {
return nil, err
}
if req.Version != l.Version {
return nil, apperr.NotFound(CodeUnavailable, "only the latest version is available")
l := cat.release(req.Version)
if l == nil {
return nil, apperr.NotFound(CodeUnavailable, "this version is no longer available")
}
return h.serveFile(ctx, l, string(req.Client))
}
@@ -10,10 +10,13 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"pipu/api/internal/api/gen"
)
func TestPickClientFiles(t *testing.T) {
@@ -51,19 +54,30 @@ func TestLatestAndDownload(t *testing.T) {
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "token secret", r.Header.Get("Authorization"))
assets := []map[string]any{
{"name": "pipu-music-0.6.1.apk", "size": len(apk), "browser_download_url": srv.URL + "/apk"},
{"name": "pipu-music-0.6.1.apk.sha256", "size": 65, "browser_download_url": srv.URL + "/sum"},
}
switch r.URL.Path {
case "/api/v1/repos/o/r/releases":
_ = json.NewEncoder(w).Encode([]map[string]any{
{"tag_name": "v0.6.1", "body": "## Новое", "draft": false, "published_at": "2026-01-02T00:00:00Z", "assets": assets},
{"tag_name": "v0.6.0", "body": "", "draft": false, "published_at": "2026-01-01T00:00:00Z", "assets": []map[string]any{
{"name": "pipu-music-0.6.0.apk", "size": 8, "browser_download_url": srv.URL + "/apk-old"},
{"name": "pipu-music-0.6.0.apk.sha256", "size": 65, "browser_download_url": srv.URL + "/sum-old"},
{"name": "pipu-music-0.6.0-windows-setup.exe", "size": 9, "browser_download_url": srv.URL + "/exe-old"},
}},
})
case "/api/v1/repos/o/r/releases/latest":
_ = json.NewEncoder(w).Encode(map[string]any{
"tag_name": "v0.6.1", "body": "## Новое",
"assets": []map[string]any{
{"name": "pipu-music-0.6.1.apk", "size": len(apk), "browser_download_url": srv.URL + "/apk"},
{"name": "pipu-music-0.6.1.apk.sha256", "size": 65, "browser_download_url": srv.URL + "/sum"},
},
"tag_name": "v0.6.1", "body": "## Новое", "assets": assets,
})
case "/apk":
_, _ = w.Write(apk)
case "/sum":
_, _ = w.Write([]byte(hex.EncodeToString(sum[:]) + "\n"))
case "/sum-old":
_, _ = w.Write([]byte(strings.Repeat("ab", 32) + "\n"))
default:
http.NotFound(w, r)
}
@@ -91,4 +105,23 @@ func TestLatestAndDownload(t *testing.T) {
assert.ErrorContains(t, err, "checksum mismatch")
_, statErr := os.Stat(filepath.Join(dir, "pipu-music-0.6.1.apk"))
assert.True(t, os.IsNotExist(statErr))
out, err := h.GetAppClients(context.Background(), gen.GetAppClientsRequestObject{})
require.NoError(t, err)
body := out.(gen.GetAppClients200JSONResponse)
var androidClient, windowsClient *gen.AppClientFile
for i := range body.Clients {
switch body.Clients[i].Client {
case gen.AppClientAndroid:
androidClient = &body.Clients[i]
case gen.AppClientWindows:
windowsClient = &body.Clients[i]
}
}
require.NotNil(t, androidClient)
require.NotNil(t, androidClient.Older)
assert.Equal(t, "0.6.0", (*androidClient.Older)[0].Version)
require.NotNil(t, windowsClient)
assert.Contains(t, windowsClient.Filename, "0.6.0")
assert.True(t, windowsClient.Older == nil || len(*windowsClient.Older) == 0)
}