diff --git a/.gitignore b/.gitignore index ee206bb..6cdafec 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,7 @@ Thumbs.db .vscode/* !.vscode/extensions.json .cache/ +apps/desktop/src-tauri/target/ +apps/desktop/src-tauri/gen/ +apps/desktop/src-tauri/tauri.release.json +apps/desktop/release/ diff --git a/api/openapi.yaml b/api/openapi.yaml index bf3c2c6..c23e4d7 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1538,6 +1538,82 @@ paths: format: binary default: $ref: "#/components/responses/Problem" + /api/v1/me/playlists: + get: + tags: [collection] + operationId: listPlaylists + responses: + default: + $ref: '#/components/responses/Problem' + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/PlaylistList' + post: + tags: [collection] + operationId: createPlaylist + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PlaylistInput' + responses: + default: + $ref: '#/components/responses/Problem' + '201': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/Playlist' + /api/v1/me/playlists/{id}: + parameters: + - in: path + name: id + required: true + schema: { type: string, format: uuid } + get: + tags: [collection] + operationId: getPlaylist + responses: + default: + $ref: '#/components/responses/Problem' + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/Playlist' + put: + tags: [collection] + operationId: updatePlaylist + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PlaylistUpdate' + responses: + default: + $ref: '#/components/responses/Problem' + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/Playlist' + delete: + tags: [collection] + operationId: deletePlaylist + responses: + default: + $ref: '#/components/responses/Problem' + '204': + description: Deleted + components: parameters: SourceId: @@ -1598,6 +1674,53 @@ components: schema: $ref: "#/components/schemas/Problem" schemas: + PlaylistInput: + type: object + required: [name, description, track_ids] + properties: + name: { type: string, minLength: 1, maxLength: 120 } + description: { type: string, maxLength: 2000 } + track_ids: + type: array + maxItems: 2000 + uniqueItems: true + items: { type: string, format: uuid } + PlaylistUpdate: + allOf: + - $ref: '#/components/schemas/PlaylistInput' + - type: object + required: [revision] + properties: + revision: { type: integer, minimum: 1 } + PlaylistSummary: + type: object + required: [id, name, description, revision, track_count, duration_ms, created_at, updated_at] + properties: + id: { type: string, format: uuid } + name: { type: string } + description: { type: string } + revision: { type: integer } + track_count: { type: integer } + duration_ms: { type: integer, format: int64 } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + Playlist: + allOf: + - $ref: '#/components/schemas/PlaylistSummary' + - type: object + required: [tracks] + properties: + tracks: + type: array + items: { $ref: '#/components/schemas/Track' } + PlaylistList: + type: object + required: [items] + properties: + items: + type: array + items: { $ref: '#/components/schemas/PlaylistSummary' } + Onboarding: type: object required: [required, artists, genres] @@ -2100,7 +2223,7 @@ components: enum: [admin, moderator, user] ClientType: type: string - enum: [web, android] + enum: [web, android, desktop] LoginRequest: type: object required: [username, password, client_type] @@ -2120,7 +2243,7 @@ components: properties: refresh_token: type: string - description: Только Android; веб передаёт токен в cookie + description: Android и desktop; веб передаёт токен в cookie AuthTokens: type: object required: [access_token, token_type, expires_in, user] @@ -2135,7 +2258,7 @@ components: description: Срок жизни access-токена, секунды refresh_token: type: string - description: "Только для `client_type: android`" + description: "Для `client_type: android` и `client_type: desktop`" user: $ref: "#/components/schemas/Me" UiPrefs: diff --git a/apps/mobile/src/app/(tabs)/collection.tsx b/apps/mobile/src/app/(tabs)/collection.tsx index 95b2c03..c989e72 100644 --- a/apps/mobile/src/app/(tabs)/collection.tsx +++ b/apps/mobile/src/app/(tabs)/collection.tsx @@ -1,4 +1,5 @@ import { unwrap, type Track } from '@pipu/core'; +import { useRouter } from 'expo-router'; import { useInfiniteQuery } from '@tanstack/react-query'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -20,6 +21,7 @@ export default function CollectionScreen() { const session = useSession(); const current = usePlayerState().track; const [tab, setTab] = useState('likes'); + const router = useRouter(); const list = useInfiniteQuery({ queryKey: ['m-collection', tab], @@ -54,6 +56,8 @@ export default function CollectionScreen() { const empty = { history: 'collection.empty_history', likes: 'collection.empty_likes', dislikes: 'collection.empty_dislikes' } as const; const tabs = ( + + + + + ; +} + +/** Mounted only while open, so catalog rows do not each subscribe to the playlist list. */ +function PlaylistPicker({ trackIds, onClose }: { trackIds: string[]; onClose: () => void }) { + const { t } = useTranslation(); + const session = useSession(); + const cache = useQueryClient(); + const errorText = useErrorText(); + const list = usePlaylists(); + const [create, setCreate] = useState(false); + const add = useMutation({ mutationFn: async (id: string) => { + const p = unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } })); + const body = playlistInput(p); + body.track_ids = [...new Set([...body.track_ids, ...trackIds])]; + return unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id } }, body })); + }, onSuccess: () => { void cache.invalidateQueries({ queryKey: ['playlists'] }); onClose(); } }); + if (create) return setCreate(false)} onSaved={onClose} />; + return { if (!add.isPending) onClose(); }}> + {list.error || add.error ? errorText(list.error ?? add.error) : null} + {list.isPending &&

{t('playlists.loading')}

} + {list.isSuccess && list.data.items.length === 0 &&

{t('playlists.empty')}

} +
+ {list.data?.items.map(p => )} + +
+
; +} + +export function AddToPlaylist({ trackIds, compact = false }: { trackIds: string[]; compact?: boolean }) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + return <> + {compact ? + : } + {open && setOpen(false)} />} + ; +} diff --git a/apps/web/src/pages/AlbumPage.tsx b/apps/web/src/pages/AlbumPage.tsx index 3425f99..aebd1a7 100644 --- a/apps/web/src/pages/AlbumPage.tsx +++ b/apps/web/src/pages/AlbumPage.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'; import { useSession } from '../app/session'; import { Cover, TrackList } 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'; @@ -45,6 +46,7 @@ export function AlbumPage({ id }: { id: string }) { )} {a && a.tracks.some((tr) => tr.available) && (
+ tr.id)} /> diff --git a/apps/web/src/pages/PlaylistsPage.tsx b/apps/web/src/pages/PlaylistsPage.tsx new file mode 100644 index 0000000..eb5f79f --- /dev/null +++ b/apps/web/src/pages/PlaylistsPage.tsx @@ -0,0 +1,109 @@ +import { unwrap } from '@pipu/core'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { Link, useNavigate } from '@tanstack/react-router'; +import { useState } 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 { 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 c from './catalog.module.css'; +import s from './page.module.css'; +import p from './playlists.module.css'; + +export function PlaylistsPage() { + const { t } = useTranslation(); + const list = usePlaylists(); + const errorText = useErrorText(); + const navigate = useNavigate(); + const [create, setCreate] = useState(false); + const [search, setSearch] = useState(''); + const items = list.data?.items.filter(x => x.name.toLocaleLowerCase().includes(search.toLocaleLowerCase())) ?? []; + return
+

{t('playlists.title')}

+ + {list.error ? errorText(list.error) : null} + {list.isPending &&

{t('playlists.loading')}

} + {list.isSuccess && !items.length &&

{t(search ? 'playlists.no_results' : 'playlists.empty')}

} +
{items.map(item => + + {item.name}{t('catalog.track_count', { count: item.track_count })} · {formatDuration(item.duration_ms)} + {item.description && {item.description}} + )}
+ {create && setCreate(false)} onSaved={item => { setCreate(false); void navigate({ to: '/playlist/$id', params: { id: item.id } }); }} />} +
; +} + +export function PlaylistPage({ id }: { id: string }) { + const { t } = useTranslation(); + const query = usePlaylist(id); + const errorText = useErrorText(); + return
+ ← {t('playlists.title')} + {query.error ? errorText(query.error) : null} + {query.isPending &&

{t('playlists.loading')}

} + {query.data && } +
; +} + +function PlaylistDetail({ playlist }: { playlist: Playlist }) { + const { t } = useTranslation(); + const session = useSession(); + const cache = useQueryClient(); + const player = usePlayer(); + const navigate = useNavigate(); + const errorText = useErrorText(); + const [edit, setEdit] = useState(false); + const [remove, setRemove] = useState(false); + const [manage, setManage] = useState(false); + const context = { type: 'collection' as const, id: playlist.id, label: playlist.name }; + const save = useMutation({ mutationFn: async (ids: string[]) => unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { + params: { path: { id: playlist.id } }, body: { ...playlistInput(playlist), track_ids: ids }, + })), onSuccess: data => { cache.setQueryData(['playlists', playlist.id], data); void cache.invalidateQueries({ queryKey: ['playlists'] }); }, + onError: () => { void cache.invalidateQueries({ queryKey: ['playlists'] }); } }); + const deletion = useMutation({ mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/playlists/{id}', { params: { path: { id: playlist.id } } })), + onSuccess: () => { cache.removeQueries({ queryKey: ['playlists', playlist.id] }); void cache.invalidateQueries({ queryKey: ['playlists'] }); void navigate({ to: '/collection/playlists' }); } }); + const play = (shuffle: boolean) => { + const tracks = playlist.tracks.filter(x => x.available); + if (shuffle) for (let i = tracks.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [tracks[i], tracks[j]] = [tracks[j]!, tracks[i]!]; } + if (tracks.length) player.playList(tracks, 0, context); + }; + const move = (index: number, delta: number) => { + const ids = playlist.tracks.map(x => x.id); + [ids[index], ids[index + delta]] = [ids[index + delta]!, ids[index]!]; + save.mutate(ids); + }; + return <> +
+ +
{t('playlists.private')}

{playlist.name}

+

{playlist.description}

+ {t('catalog.track_count', { count: playlist.track_count })} · {formatDuration(playlist.duration_ms)} +
+
+
+ + + + + +
+ {save.error ? errorText(save.error) : null} + {!playlist.tracks.length &&

{t('playlists.empty_tracks')}

{t('playlists.find_tracks')}
} + String(i + 1)} + actions={manage ? (track, i) => + + + + : undefined} /> + {edit && setEdit(false)} onSaved={() => setEdit(false)} />} + {remove && { if (!deletion.isPending) setRemove(false); }} footer={<> + + + }>

{t('playlists.delete_confirm', { name: playlist.name })}

{deletion.error ? errorText(deletion.error) : null}
} + ; +} diff --git a/apps/web/src/pages/TrackPage.tsx b/apps/web/src/pages/TrackPage.tsx new file mode 100644 index 0000000..7376336 --- /dev/null +++ b/apps/web/src/pages/TrackPage.tsx @@ -0,0 +1,38 @@ +import { unwrap } from '@pipu/core'; +import { useQuery } from '@tanstack/react-query'; +import { Link } from '@tanstack/react-router'; +import { useTranslation } from 'react-i18next'; + +import { useSession } from '../app/session'; +import { ArtistLinks, Cover, TrackList } from '../entities/catalog'; +import { formatDuration } from '../shared/lib/format'; +import { NotFound } from './NotFound'; +import s from './page.module.css'; +import c from './catalog.module.css'; + +export function TrackPage({ id }: { id: string }) { + const { t } = useTranslation(); + const session = useSession(); + const track = useQuery({ + queryKey: ['track', id], + queryFn: async () => unwrap(await session.client.GET('/api/v1/tracks/{id}', { params: { path: { id } } })), + }); + if (track.isError) return ; + const tr = track.data; + return ( +
+
+ {tr && } +
+ {t('player.track_page')} +

{tr?.title ?? '…'}

+ {tr && <> + · {formatDuration(tr.duration_ms)} + {tr.album.title} + } +
+
+ {tr && } +
+ ); +} diff --git a/apps/web/src/pages/playlists.module.css b/apps/web/src/pages/playlists.module.css new file mode 100644 index 0000000..70ca1f2 --- /dev/null +++ b/apps/web/src/pages/playlists.module.css @@ -0,0 +1,12 @@ +.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 strong { overflow-wrap: anywhere; } +.card span { font-size: 13px; } +.card:hover { background: #ffffff10; } +.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; } +.actions button { min-width: 44px; min-height: 44px; border: 0; background: transparent; color: inherit; cursor: pointer; font-size: 20px; } +.actions button:disabled { opacity: .3; cursor: default; } +@media (max-width: 600px) { .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } .card { padding: 8px; } } diff --git a/apps/web/src/shared/lib/share.test.ts b/apps/web/src/shared/lib/share.test.ts new file mode 100644 index 0000000..1436434 --- /dev/null +++ b/apps/web/src/shared/lib/share.test.ts @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { shareTrack } from './share'; + +afterEach(() => vi.restoreAllMocks()); + +describe('shareTrack', () => { + it('copies on desktop even when native sharing exists', async () => { + const share = vi.fn(); + const writeText = vi.fn().mockResolvedValue(undefined); + vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Windows'); + vi.stubGlobal('navigator', { userAgent: 'Windows', share, clipboard: { writeText } }); + try { + expect(await shareTrack('id', 'Title')).toBe('copied'); + expect(writeText).toHaveBeenCalledWith(`${window.location.origin}/track/id`); + expect(share).not.toHaveBeenCalled(); + } finally { vi.unstubAllGlobals(); } + }); + + it('opens Android sharing with a page URL', async () => { + const share = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('navigator', { userAgent: 'Android', share }); + try { + expect(await shareTrack('id', 'Title')).toBe('shared'); + expect(share).toHaveBeenCalledWith({ title: 'Title', url: `${window.location.origin}/track/id` }); + } finally { vi.unstubAllGlobals(); } + }); + + it('does not copy or report an error when the share sheet is dismissed', async () => { + const share = vi.fn().mockRejectedValue(new DOMException('Cancelled', 'AbortError')); + vi.stubGlobal('navigator', { userAgent: 'Android', share }); + try { expect(await shareTrack('id', 'Title')).toBe('cancelled'); } + finally { vi.unstubAllGlobals(); } + }); + + it('reports clipboard failures instead of pretending the link was copied', async () => { + vi.stubGlobal('navigator', { userAgent: 'Windows', clipboard: { writeText: vi.fn().mockRejectedValue(new Error('Denied')) } }); + try { await expect(shareTrack('id', 'Title')).rejects.toThrow('Denied'); } + finally { vi.unstubAllGlobals(); } + }); +}); diff --git a/apps/web/src/shared/lib/share.ts b/apps/web/src/shared/lib/share.ts new file mode 100644 index 0000000..911c76d --- /dev/null +++ b/apps/web/src/shared/lib/share.ts @@ -0,0 +1,17 @@ +import { trackShareUrl } from '@pipu/core'; + +/** Desktop always copies; mobile browsers can open their native share sheet. */ +export async function shareTrack(id: string, title: string): Promise<'copied' | 'shared' | 'cancelled'> { + const url = trackShareUrl(window.location.origin, id); + if (/Android|iPhone|iPad|iPod/i.test(navigator.userAgent) && navigator.share) { + try { + await navigator.share({ title, url }); + return 'shared'; + } catch (error) { + if (typeof error === 'object' && error !== null && 'name' in error && error.name === 'AbortError') return 'cancelled'; + // If sharing is unavailable, still allow copying the link. + } + } + await navigator.clipboard.writeText(url); + return 'copied'; +} diff --git a/apps/web/src/shared/ui/icons.ts b/apps/web/src/shared/ui/icons.ts index 47e2d57..43f1703 100644 --- a/apps/web/src/shared/ui/icons.ts +++ b/apps/web/src/shared/ui/icons.ts @@ -3,6 +3,7 @@ export { default as addIcon } from '@material-symbols/svg-400/rounded/add.svg'; export { default as checkIcon } from '@material-symbols/svg-400/rounded/check.svg'; export { default as closeIcon } from '@material-symbols/svg-400/rounded/close.svg'; export { default as copyIcon } from '@material-symbols/svg-400/rounded/content_copy.svg'; +export { default as shareIcon } from '@material-symbols/svg-400/rounded/share.svg'; export { default as groupIcon } from '@material-symbols/svg-400/rounded/group.svg'; export { default as groupFillIcon } from '@material-symbols/svg-400/rounded/group-fill.svg'; export { default as homeIcon } from '@material-symbols/svg-400/rounded/home.svg'; diff --git a/apps/web/src/test/fakeApi.ts b/apps/web/src/test/fakeApi.ts index b87f5c6..c8bd29f 100644 --- a/apps/web/src/test/fakeApi.ts +++ b/apps/web/src/test/fakeApi.ts @@ -75,6 +75,7 @@ export function fakeApi(initial: FakeUser[]) { let token = 0; const calls: { method: string; path: string; body: unknown }[] = []; const reactions = new Map(); + const playlists = new Map(); const me = (u: FakeUser): Me => ({ id: u.id, @@ -127,6 +128,23 @@ export function fakeApi(initial: FakeUser[]) { if (!user || !req.headers.get('Authorization')) return problem(401, 'auth.unauthorized'); + if (path === '/api/v1/me/playlists' && req.method === 'GET') return json({ items: [...playlists.values()] }); + const playlistPath = /^\/api\/v1\/me\/playlists\/([^/]+)$/.exec(path); + if (path === '/api/v1/me/playlists' && req.method === 'POST' || playlistPath) { + const id = playlistPath?.[1] ?? `00000000-0000-4000-9000-${String(++seq).padStart(12, '0')}`; + const old = playlists.get(id); + if (playlistPath && !old) return problem(404, 'playlist.not_found'); + if (req.method === 'GET') return json(old); + if (req.method === 'DELETE') { playlists.delete(id); return new Response(null, { status: 204 }); } + if (old && body?.revision !== old.revision) return problem(409, 'playlist.conflict'); + const tracks = (body?.track_ids as string[]).map(id => albumTracks.find(t => t.id === id)!); + const item = { id, name: body?.name as string, description: body?.description as string, tracks, + track_count: tracks.length, duration_ms: tracks.reduce((sum, t) => sum + t.duration_ms, 0), + revision: (old?.revision ?? 0) + 1, created_at: old?.created_at ?? new Date().toISOString(), updated_at: new Date().toISOString() }; + playlists.set(id, item); + return json(item, old ? 200 : 201); + } + switch (route) { case 'POST /api/v1/auth/logout': case 'POST /api/v1/auth/logout-all': @@ -193,11 +211,14 @@ export function fakeApi(initial: FakeUser[]) { // «rbyj» — «кино» в английской раскладке: сервер находит и сообщает исправление. const q = url.searchParams.get('q') ?? ''; const hit = q === 'кино' || q === 'rbyj'; + const tracks = path === '/api/v1/search' && hit + ? albumTracks.map((t) => ({ ...t, reaction: reactions.get(t.id) ?? null })) + : []; return json({ artists: hit ? [kino] : [], albums: hit ? [albumSummary] : [], - tracks: [], - totals: { artists: hit ? 1 : 0, albums: hit ? 1 : 0, tracks: 0 }, + tracks, + totals: { artists: hit ? 1 : 0, albums: hit ? 1 : 0, tracks: tracks.length }, corrected_query: q === 'rbyj' ? 'кино' : null, next_cursor: null, }); @@ -260,6 +281,11 @@ export function fakeApi(initial: FakeUser[]) { return json(admin(target)); } const reactionPath = /^\/api\/v1\/me\/reactions\/tracks\/([^/]+)$/.exec(path); + const trackPath = /^\/api\/v1\/tracks\/([^/]+)$/.exec(path); + if (trackPath && req.method === 'GET') { + const track = albumTracks.find(t => t.id === trackPath[1]); + return track ? json({ ...track, reaction: reactions.get(track.id) ?? null }) : problem(404, 'common.not_found'); + } if (reactionPath) { if (req.method === 'PUT') reactions.set(reactionPath[1]!, body?.reaction as 'like' | 'dislike'); else reactions.delete(reactionPath[1]!); @@ -268,5 +294,5 @@ export function fakeApi(initial: FakeUser[]) { return problem(404, 'common.not_found'); }; - return { fetch: fetchImpl as typeof fetch, calls, users, signOut: () => (current = null) }; + return { fetch: fetchImpl as typeof fetch, calls, users, playlists, signOut: () => (current = null) }; } diff --git a/apps/web/src/widgets/AppShell.tsx b/apps/web/src/widgets/AppShell.tsx index 368ba26..2e704b3 100644 --- a/apps/web/src/widgets/AppShell.tsx +++ b/apps/web/src/widgets/AppShell.tsx @@ -1,3 +1,4 @@ +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'; @@ -46,6 +47,7 @@ type NavPath = | '/uploads/new' | '/uploads/mine' | '/admin/settings' + | '/collection/playlists' | '/collection/history' | '/collection/likes' | '/collection/dislikes'; @@ -169,6 +171,7 @@ function Shell() { <> + :not(.queueButton) { display: none; } + .right > :not(.queueButton):not(.shareButton) { display: none; } .drawer, .drawerIsland { bottom: calc(144px + env(safe-area-inset-bottom, 0px)); max-height: calc(100dvh - 180px - env(safe-area-inset-bottom, 0px)); } } @@ -318,3 +319,16 @@ a.source:hover { @media (max-width: 359px) { .now > :first-child { display: none; } } + +.shareStatus { + position: absolute; + bottom: calc(100% + 8px); + right: 8px; + max-width: calc(100% - 16px); + padding: 10px 16px; + border-radius: 12px; + background: var(--surface3); + color: var(--text); + box-shadow: var(--shadow); + font-size: 13px; +} diff --git a/apps/web/src/widgets/PlayerBar.tsx b/apps/web/src/widgets/PlayerBar.tsx index 6e946a0..d87d715 100644 --- a/apps/web/src/widgets/PlayerBar.tsx +++ b/apps/web/src/widgets/PlayerBar.tsx @@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next'; import { ArtistLinks, Cover } from '../entities/catalog'; import { usePlayer, usePlayerState } from '../player/context'; import { formatDuration } from '../shared/lib/format'; +import { shareTrack } from '../shared/lib/share'; import { albumIcon, closeIcon, @@ -22,6 +23,7 @@ import { repeatIcon, repeatOneIcon, shuffleIcon, + shareIcon, volumeIcon, volumeOffIcon, waveIcon, @@ -35,6 +37,16 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) { const player = usePlayer(); const st = usePlayerState(); const [queueOpen, setQueueOpen] = useState(false); + const [shareStatus, setShareStatus] = useState<'copied' | 'error' | null>(null); + const [sharing, setSharing] = useState(false); + useEffect(() => { + setShareStatus(null); + }, [st.track?.id]); + useEffect(() => { + if (!shareStatus) return; + const timer = setTimeout(() => setShareStatus(null), 4000); + return () => clearTimeout(timer); + }, [shareStatus]); const queueButton = useRef(null); const queueId = useId(); const closeQueue = useCallback(() => { @@ -57,6 +69,18 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) { } const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined); + const share = async () => { + setSharing(true); + setShareStatus(null); + try { + const result = await shareTrack(track.id, track.title); + if (result === 'copied') setShareStatus('copied'); + } catch { + setShareStatus('error'); + } finally { + setSharing(false); + } + }; const repeatLabel = t(`player.repeat_${st.repeat}`); const ctx = st.context; const tr = t as unknown as (k: string, o?: Record) => string; @@ -67,7 +91,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) { ? t('player.source_wave_seed', { label: ctx.label }) : t('player.source_wave') : ctx.type === 'collection' - ? tr(`player.source_${ctx.id === 'dislikes' ? 'dislikes' : 'likes'}`) + ? ctx.label ?? tr(`player.source_${ctx.id === 'dislikes' ? 'dislikes' : 'likes'}`) : tr(`player.source_${ctx.type}`, { label: ctx.label ?? '' }); return ( @@ -154,6 +178,9 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
+
{queueOpen && } + {shareStatus && {t(shareStatus === 'copied' ? 'player.link_copied' : 'player.share_error')}} ); } diff --git a/docs/IDEA.md b/docs/IDEA.md index 5396595..5f17f52 100644 --- a/docs/IDEA.md +++ b/docs/IDEA.md @@ -57,19 +57,19 @@ ### Левая панель навигации - **Главная** - **Коллекция** — с подкатегориями: + - **Плейлисты** — личные подборки треков; - **История** — прослушанные треки; - **Лайки** — понравившиеся треки; - **Дизлайки** — треки, которые пользователь не хочет слышать (можно убрать дизлайк); - **Скачанное** — только в мобильном приложении, треки, сохранённые для офлайна. - **Админка** — видна только администраторам и модераторам. -- Список будет расширяться в будущем (плейлисты и т.п.), дизайн должен это допускать. На мобильном вместо левой панели — нижняя навигация (Главная / Поиск / Коллекция / Профиль), плеер — мини-полоска над навигацией, разворачивается в полноэкранный плеер. ### Плеер (всегда внизу) - Полноценный плеер в виде полоски: обложка, название трека, исполнитель, назад / пауза / вперёд, - прогресс-бар с перемоткой, время, громкость, **лайк** и **дизлайк**, открыть очередь. + прогресс-бар с перемоткой, время, громкость, **лайк** и **дизлайк**, поделиться ссылкой на трек, открыть очередь. - **Запоминает последний трек и момент**, на котором остановились: после перезахода / перезагрузки и даже на другом устройстве можно продолжить с того же места. - Если играет «Моя волна», плеер показывает это (и выбранное настроение / занятие). diff --git a/docs/README.md b/docs/README.md index 27ed382..cdbafcd 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,6 +15,7 @@ Pipu Music — self-hosted музыкальный сервис (веб + Android | [library-and-storage.md](library-and-storage.md) | Хранилища (NFS), шаблоны путей, сканер, метаданные, поиск | | [uploads.md](uploads.md) | Предложение треков модераторами: очередь, квота, дубли, одобрение, раскладка в хранилище | | [playback.md](playback.md) | Стриминг, транскодинг, состояние плеера, офлайн | +| [playlists.md](playlists.md) | Личные плейлисты: API, веб и Android | | [recommendations.md](recommendations.md) | Рекомендательная система и «Моя волна» | | [web.md](web.md) | Веб-клиент | | [mobile.md](mobile.md) | Android-приложение, офлайн, самообновление | diff --git a/docs/api.md b/docs/api.md index bc85e93..20b2170 100644 --- a/docs/api.md +++ b/docs/api.md @@ -91,6 +91,8 @@ | PUT | `/me/reactions/tracks/{id}` | `{reaction: "like"|"dislike"}` | | DELETE | `/me/reactions/tracks/{id}` | Снять реакцию | | PUT/DELETE | `/me/reactions/artists/{id}` | Реакция на исполнителя | +| GET/POST | `/me/playlists` | Сводки личных плейлистов; создание `{name, description, track_ids}` | +| GET/PUT/DELETE | `/me/playlists/{id}` | Метаданные и треки; атомарная замена с `revision`; удаление. Подробно — [playlists.md](playlists.md) | ### Моя волна — [recommendations.md](recommendations.md) | Метод | Путь | Описание | diff --git a/docs/data-model.md b/docs/data-model.md index 3c2a4a6..9afc41e 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -41,7 +41,7 @@ CREATE TABLE refresh_tokens ( family_started_at timestamptz NOT NULL, -- время входа: абсолютный срок семьи (180 дней) token_hash bytea NOT NULL UNIQUE, -- SHA-256 от токена device_name text, -- "Chrome / Windows", "Pixel 8" - client_type text NOT NULL, -- 'web' | 'android' + client_type text NOT NULL, -- 'web' | 'android' | 'desktop' created_at timestamptz NOT NULL DEFAULT now(), expires_at timestamptz NOT NULL, used_at timestamptz, -- ротирован @@ -303,6 +303,25 @@ CREATE TABLE track_reactions ( -- Коллекция → Лайки ); CREATE INDEX ON track_reactions (user_id, reaction, created_at DESC); +CREATE TABLE playlists ( + id uuid PRIMARY KEY, + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 120), + description text NOT NULL DEFAULT '' CHECK (char_length(description) <= 2000), + revision integer NOT NULL DEFAULT 1, -- оптимистичная блокировка при PUT + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX playlists_owner ON playlists(user_id, updated_at DESC, id); + +CREATE TABLE playlist_tracks ( + playlist_id uuid NOT NULL REFERENCES playlists(id) ON DELETE CASCADE, + track_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE, + position integer NOT NULL CHECK (position >= 0), + PRIMARY KEY (playlist_id, track_id), + UNIQUE (playlist_id, position) +); + CREATE TABLE artist_reactions ( -- «не рекомендовать исполнителя» и т.п. user_id uuid REFERENCES users(id) ON DELETE CASCADE, artist_id uuid REFERENCES artists(id) ON DELETE CASCADE, @@ -324,7 +343,7 @@ CREATE TABLE play_events ( context_type text, -- wave | album | artist | search | collection | history | queue context_id text, -- id альбома / wave_session и т.п. wave_session_id uuid, - client_type text NOT NULL, -- web | android + client_type text NOT NULL, -- web | android | desktop offline boolean NOT NULL DEFAULT false, PRIMARY KEY (id, occurred_at) ) PARTITION BY RANGE (occurred_at); diff --git a/docs/mobile.md b/docs/mobile.md index be4fe67..451325e 100644 --- a/docs/mobile.md +++ b/docs/mobile.md @@ -42,12 +42,12 @@ приложения), список сохранённых серверов, затем логин, пароль, язык. Далее те же setup-экраны, что и в вебе. - **Нижняя навигация**: Главная · Поиск · Коллекция · Профиль. Мини-плеер над навигацией; свайп вверх — полноэкранный плеер в одном из двух видов (выбор в профиле): `cover` — крупная обложка (по умолчанию) или `wave` — визуализация волны; - прогресс, лайк / дизлайк, скачать, очередь (нижний лист). + прогресс, лайк / дизлайк, скачать, поделиться ссылкой на `/track/{id}`, очередь (нижний лист). - **Поиск**: недавние запросы (хранятся локально), подсказка про опечатки и раскладку, значок «скачано» у треков. - **Главная**: кнопка «Моя волна» (`orb` или `liquid` по настройке, тот же алгоритм, что в вебе, на Skia; пульс в такт BPM / energy из `features` трека — на Android спектр недоступен из ExoPlayer без нативного модуля, поэтому анимация управляется метаданными и позицией трека), выбор занятия / настроения. -- **Коллекция**: История, Лайки, Дизлайки, **Скачанное**. +- **Коллекция**: кнопка **Плейлисты**, затем История, Лайки, Дизлайки, **Скачанное**. Плейлисты — [playlists.md](playlists.md). - **Коллекция → Скачанное**: занято на устройстве / свободно в телефоне, «Скачивать новые лайки», «Скачать все лайки», очередь загрузок с пометкой «по Wi-Fi». - **Профиль**: смена пароля (лист), язык, тема, вид волны, вид плеера, качество звука (лист: Wi-Fi / сотовая / загрузки), diff --git a/docs/playlists.md b/docs/playlists.md new file mode 100644 index 0000000..1537f50 --- /dev/null +++ b/docs/playlists.md @@ -0,0 +1,28 @@ +# Личные плейлисты + +Плейлисты хранятся в PostgreSQL и доступны владельцу на вебе и в React Native-клиенте. В вебе раздел находится в коллекции; на узком экране — в меню «Ещё». В мобильном клиенте — кнопка «Плейлисты» в коллекции. + +Можно создать плейлист, изменить название и описание, добавить трек из поиска, альбома или коллекции, убрать трек и переместить его выше/ниже. В вебе можно добавить целый альбом. Один трек встречается в плейлисте один раз: повторное добавление не создаёт дубль. Воспроизведение доступно в сохранённом или случайном порядке; недоступные треки остаются видимыми, но пропускаются при запуске всего плейлиста. Удаление плейлиста требует подтверждения и не удаляет музыку из каталога. + +## API + +- `GET /api/v1/me/playlists` — сводки личных плейлистов, последние изменённые первыми. +- `POST /api/v1/me/playlists` — создание: `name`, `description`, `track_ids`. +- `GET /api/v1/me/playlists/{id}` — метаданные и упорядоченные треки с персональными реакциями и подписанными ссылками. +- `PUT /api/v1/me/playlists/{id}` — атомарная замена метаданных и порядка треков; дополнительно требуется `revision` из прочитанного плейлиста. +- `DELETE /api/v1/me/playlists/{id}` — удаление. + +Все операции требуют авторизации. Чужой и отсутствующий плейлист возвращают одинаковый `404`. Изменения блокируют строку плейлиста в транзакции; устаревшая версия возвращает `409 playlist.conflict`. Редактор сохраняет исходную версию до закрытия, даже если фоновый запрос получил новую. Ошибка трека откатывает изменение целиком. Ввод ограничен 120 символами названия, 2000 символами описания и 2000 уникальными треками. + +Миграция `00013_playlists.sql` создаёт таблицы `playlists`, `playlist_tracks` и индексы. Удаление пользователя очищает его плейлисты; удаление трека из каталога очищает его членство. Таблицы не затрагивают музыкальные файлы. + +Плейлисты личные; публичные ссылки, совместное редактирование и загрузка пользовательских обложек не входят в эту реализацию. Карточки имеют градиентную обложку, страница использует обложку первого трека. Контекст воспроизведения — `collection` с UUID и названием плейлиста. + +## Проверки + +- `pnpm --filter @pipu/web test src/App.test.tsx -t playlists` +- `pnpm --filter @pipu/web test:e2e e2e/playlists.spec.ts` +- `pnpm --filter @pipu/web build` +- `pnpm --filter @pipu/mobile typecheck` +- В `services/api`: `go test ./internal/playback ./internal/server` +- С отдельной тестовой PostgreSQL/pgvector: `TEST_DATABASE_URL=… go test -tags integration ./internal/playback -run TestPlaylistPersistenceAndIsolation -v`. Проверяет сохранение порядка, изоляцию владельцев, откат при несуществующем треке, конфликт конкурентных записей и каскадное удаление членства. diff --git a/docs/web.md b/docs/web.md index 029b7ef..18afa56 100644 --- a/docs/web.md +++ b/docs/web.md @@ -23,7 +23,7 @@ apps/web/src/ ├── app/ # провайдеры, роутер, глобальные стили, гарды ├── pages/ # login, setup-admin, setup-password, home, search, artist, album, -│ # collection/{history,likes,dislikes}, settings, +│ # collection/{playlists,history,likes,dislikes}, playlist, track, settings, │ # admin/{users,sources,library,upload,upload-queue,settings} ├── widgets/ # app-shell (топбар, сайдбар), player-bar, wave-button, search-box, track-list ├── features/ # like-track, change-password, mood-picker, create-user, pattern-editor, ... @@ -37,8 +37,8 @@ apps/web/src/ ### App shell Каркас **Classic** ([design.md](design.md#4-каркас-и-экраны)). -- Сетка: топбар 60 px (логотип, поиск, меню профиля) / сайдбар 240 px (Главная, Коллекция → История / Лайки / Дизлайки, - Админка по роли) / контент / плеер. +- Сетка: топбар 60 px (логотип, поиск, меню профиля) / сайдбар 240 px (Главная, Коллекция → Плейлисты / История / Лайки / Дизлайки, + Админка по роли) / контент / плеер. Личные плейлисты — [playlists.md](playlists.md). - Плеер в двух видах, выбор в профиле: `bar` — полоса 88 px внизу (по умолчанию), `island` — плавающий остров. - Ambient-фон за всем интерфейсом: три размытых дрейфующих пятна, одно — в цвете обложки текущего трека. - Адаптивность: < 1024 px — сайдбар из иконок, < 768 px — нижняя навигация, как в мобильном. @@ -55,6 +55,7 @@ apps/web/src/ - Два элемента `