feat(track): play-on-hover cover art, replacing double-click
Add a play overlay button shown on cover art hover that inserts the track into the queue right after the current track and jumps to it, so it takes priority over what's already queued. Replaces the double-click-to-play interaction with a new playNow queue action.
This commit is contained in:
@@ -1,10 +1,12 @@
|
|||||||
import { Row } from '@olly/modern-sk';
|
import { Row } from '@olly/modern-sk';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { TrackContextMenu } from './TrackContextMenu';
|
import { TrackContextMenu } from './TrackContextMenu';
|
||||||
import { AvailabilityBadge } from './AvailabilityBadge';
|
import { AvailabilityBadge } from './AvailabilityBadge';
|
||||||
import { MetadataStatusBadge } from './MetadataStatusBadge';
|
import { MetadataStatusBadge } from './MetadataStatusBadge';
|
||||||
|
import { Icon } from '../common/Icon';
|
||||||
import { formatDuration } from '../../lib/format';
|
import { formatDuration } from '../../lib/format';
|
||||||
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
||||||
import { play } from '../../store/slices/player';
|
import { playNow } from '../../store/slices/queue';
|
||||||
import type { Track } from '../../api/types';
|
import type { Track } from '../../api/types';
|
||||||
import { getCoverUrl, getTrackCoverUrl } from '../../api/endpoints/streaming';
|
import { getCoverUrl, getTrackCoverUrl } from '../../api/endpoints/streaming';
|
||||||
|
|
||||||
@@ -25,6 +27,7 @@ export function TrackRow({
|
|||||||
onEditMetadata,
|
onEditMetadata,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const currentTrackId = useAppSelector((s) => s.player.currentTrackId);
|
const currentTrackId = useAppSelector((s) => s.player.currentTrackId);
|
||||||
const isPlaying = useAppSelector((s) => s.player.isPlaying);
|
const isPlaying = useAppSelector((s) => s.player.isPlaying);
|
||||||
@@ -36,10 +39,22 @@ export function TrackRow({
|
|||||||
getCoverUrl(track.albumArtUrl) ??
|
getCoverUrl(track.albumArtUrl) ??
|
||||||
(token ? getTrackCoverUrl(track.id, token, track.hasCover) : undefined);
|
(token ? getTrackCoverUrl(track.id, token, track.hasCover) : undefined);
|
||||||
|
|
||||||
|
const handlePlayNow = () => {
|
||||||
|
dispatch(
|
||||||
|
playNow({
|
||||||
|
trackId: track.id,
|
||||||
|
title: track.title,
|
||||||
|
artistName: track.artistName,
|
||||||
|
albumTitle: track.albumTitle,
|
||||||
|
durationMs: track.durationMs,
|
||||||
|
albumArtUrl: track.albumArtUrl,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row
|
<Row
|
||||||
selected={isActive}
|
selected={isActive}
|
||||||
onDoubleClick={() => dispatch(play(track.id))}
|
|
||||||
style={{
|
style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: '2rem 2.5rem 1fr auto auto',
|
gridTemplateColumns: '2rem 2.5rem 1fr auto auto',
|
||||||
@@ -58,24 +73,38 @@ export function TrackRow({
|
|||||||
>
|
>
|
||||||
{isActive && isPlaying ? '▶' : index !== undefined ? index + 1 : ''}
|
{isActive && isPlaying ? '▶' : index !== undefined ? index + 1 : ''}
|
||||||
</span>
|
</span>
|
||||||
{artUrl ? (
|
<div
|
||||||
<img
|
className="track-art"
|
||||||
src={artUrl}
|
style={{ position: 'relative', width: 36, height: 36 }}
|
||||||
alt=""
|
>
|
||||||
width={36}
|
{artUrl ? (
|
||||||
height={36}
|
<img
|
||||||
style={{ borderRadius: 4, objectFit: 'cover' }}
|
src={artUrl}
|
||||||
/>
|
alt=""
|
||||||
) : (
|
width={36}
|
||||||
<div
|
height={36}
|
||||||
style={{
|
style={{ borderRadius: 4, objectFit: 'cover' }}
|
||||||
width: 36,
|
/>
|
||||||
height: 36,
|
) : (
|
||||||
borderRadius: 4,
|
<div
|
||||||
background: 'var(--color-surface-3)',
|
style={{
|
||||||
}}
|
width: 36,
|
||||||
/>
|
height: 36,
|
||||||
)}
|
borderRadius: 4,
|
||||||
|
background: 'var(--color-surface-3)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="track-art-play"
|
||||||
|
onClick={handlePlayNow}
|
||||||
|
aria-label={t('track.menu.playNow')}
|
||||||
|
title={t('track.menu.playNow')}
|
||||||
|
>
|
||||||
|
<Icon name="play" fill />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div style={{ minWidth: 0 }}>
|
<div style={{ minWidth: 0 }}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -59,6 +59,11 @@ export const queueSlice = createSlice({
|
|||||||
addNextInQueue(state, action: PayloadAction<QueueEntry>) {
|
addNextInQueue(state, action: PayloadAction<QueueEntry>) {
|
||||||
state.entries.splice(state.currentIndex + 1, 0, action.payload);
|
state.entries.splice(state.currentIndex + 1, 0, action.payload);
|
||||||
},
|
},
|
||||||
|
playNow(state, action: PayloadAction<QueueEntry>) {
|
||||||
|
const insertAt = state.currentIndex + 1;
|
||||||
|
state.entries.splice(insertAt, 0, action.payload);
|
||||||
|
state.currentIndex = insertAt;
|
||||||
|
},
|
||||||
removeFromQueue(state, action: PayloadAction<number>) {
|
removeFromQueue(state, action: PayloadAction<number>) {
|
||||||
state.entries.splice(action.payload, 1);
|
state.entries.splice(action.payload, 1);
|
||||||
if (action.payload < state.currentIndex) state.currentIndex--;
|
if (action.payload < state.currentIndex) state.currentIndex--;
|
||||||
@@ -93,6 +98,7 @@ export const {
|
|||||||
setQueue,
|
setQueue,
|
||||||
addToQueue,
|
addToQueue,
|
||||||
addNextInQueue,
|
addNextInQueue,
|
||||||
|
playNow,
|
||||||
removeFromQueue,
|
removeFromQueue,
|
||||||
moveInQueue,
|
moveInQueue,
|
||||||
goToIndex,
|
goToIndex,
|
||||||
|
|||||||
@@ -951,3 +951,28 @@
|
|||||||
.sb-sec-link.active {
|
.sb-sec-link.active {
|
||||||
color: var(--fg-1);
|
color: var(--fg-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
TRACK ROW — cover art play overlay
|
||||||
|
============================================================ */
|
||||||
|
.track-art-play {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
color: var(--fg-1);
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--dur-quick);
|
||||||
|
}
|
||||||
|
.track-art:hover .track-art-play {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.track-art-play:hover {
|
||||||
|
color: var(--lime);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user