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
@@ -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() {
|
||||
<>
|
||||
<NavLink to="/" label={t('nav.home')} icon={homeIcon} activeIcon={homeFillIcon} />
|
||||
<NavSection mobile={mobile} id="collection" title={t('nav.collection')}>
|
||||
<NavLink to="/collection/playlists" label={t('playlists.title')} icon={queueIcon} activeIcon={queueIcon} />
|
||||
<NavLink to="/collection/history" label={t('nav.history')} icon={historyIcon} activeIcon={historyFillIcon} />
|
||||
<NavLink to="/collection/likes" label={t('nav.likes')} icon={likeIcon} activeIcon={likeFillIcon} count={counts.likes} />
|
||||
<NavLink
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
.bar {
|
||||
position: relative;
|
||||
grid-area: player;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.3fr) minmax(0, 1fr);
|
||||
@@ -310,7 +311,7 @@ a.source:hover {
|
||||
.controls { gap: 2px; }
|
||||
.play, .icon { width: 44px; height: 44px; }
|
||||
.right { display: flex; gap: 0; }
|
||||
.right > :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;
|
||||
}
|
||||
@@ -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<HTMLButtonElement>(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, unknown>) => 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' }) {
|
||||
</div>
|
||||
|
||||
<div className={s.right}>
|
||||
<button type="button" className={cx(s.icon, s.shareButton)} aria-label={t('player.share')} title={t('player.share')} disabled={sharing} onClick={() => void share()}>
|
||||
<Icon src={shareIcon} size={20} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cx(s.icon, st.shuffle && s.accent)}
|
||||
@@ -204,6 +231,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
||||
/>
|
||||
</div>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user