feat: add personal playlists and shareable track links
EOF Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
3a097c15c8
commit
fa79394869
46 files changed
+2307
-292
No files matched your search
@@ -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(); }
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in new issue
Block a user