feat: add personal playlists and shareable track links
release / release (версия и заметки) (push) Successful in 6s
release / android (APK в релиз) (push) Successful in 4m8s

EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
mrleo1nidandCursor committed 2026-10-21 23:37:53 +03:00
1 parent 3a097c15c8
commit fa79394869
46 files changed
+2307 -292

No files matched your search

+40
View File
@@ -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(); }
});
});
+17
View File
@@ -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';
}
+1
View File
@@ -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';