feat(track): play-on-hover cover art, replacing double-click
Docker Build & Publish / Prune old image versions (push) Has been cancelled
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled

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:
Senko-san
2026-06-13 17:44:35 +03:00
parent b37fabd936
commit 3984c7a499
3 changed files with 80 additions and 20 deletions
+49 -20
View File
@@ -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={{
+6
View File
@@ -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,
+25
View File
@@ -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);
}