feat(upload): enhance audio upload handling and user feedback
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m1s
ci / db / db (push) Successful in 1m16s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m12s
ci / api / api (push) Successful in 45s
ci / recommender / recommender (push) Successful in 1m50s
ci / ts / ts (packages + web) (push) Failing after 6m53s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m13s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m1s
ci / db / db (push) Successful in 1m16s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m12s
ci / api / api (push) Successful in 45s
ci / recommender / recommender (push) Successful in 1m50s
ci / ts / ts (packages + web) (push) Failing after 6m53s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m13s
- Introduced a new warning for users uploading non-FLAC or WAV formats, indicating that these formats should only be used as a last resort. - Added functionality to highlight files needing format attention in the upload queue. - Updated the UI to provide clearer guidance on acceptable audio formats during uploads. - Improved documentation to reflect changes in upload requirements and user interface updates.
This commit is contained in:
1 parent
74da457362
commit
1e3b350dfc
49 files changed
+147
-5572
No files matched your search
@@ -315,6 +315,15 @@ describe('my wave', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('suggest tracks', () => {
|
||||
it('warns that FLAC and WAV are the formats to send', async () => {
|
||||
const admin = makeUser('admin', 'admin');
|
||||
await renderApp([admin], '/uploads/new', { signedInAs: admin });
|
||||
expect(await screen.findByText(/Нужны FLAC или WAV/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/MP3 — только в крайнем случае/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('home and login have no serious axe violations', async () => {
|
||||
const alice = makeUser('alice', 'user');
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { needsFormatAttention } from './files';
|
||||
|
||||
describe('needsFormatAttention', () => {
|
||||
it('marks everything except flac and wav', () => {
|
||||
expect(needsFormatAttention('flac', 'a.mp3')).toBe(false);
|
||||
expect(needsFormatAttention('WAV', 'track.wav')).toBe(false);
|
||||
expect(needsFormatAttention(null, 'Кино/01.wav')).toBe(false);
|
||||
expect(needsFormatAttention('mp3', 'a.flac')).toBe(true);
|
||||
expect(needsFormatAttention(null, 'folder/song.MP3')).toBe(true);
|
||||
expect(needsFormatAttention(null, 'song.m4a')).toBe(true);
|
||||
expect(needsFormatAttention(null, 'noext')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,22 @@ export interface PickedFile {
|
||||
/** Форматы, которые принимает очередь (сервер всё равно проверяет содержимое через ffprobe). */
|
||||
export const AUDIO_EXTENSIONS = ['flac', 'mp3', 'm4a', 'ogg', 'opus', 'wav', 'aiff', 'aif', 'wv'];
|
||||
|
||||
/** То, что просим присылать. MP3 и прочие — крайний случай, их помечает очередь. */
|
||||
const PREFERRED_AUDIO = new Set(['flac', 'wav']);
|
||||
|
||||
export function audioExt(name: string): string {
|
||||
const base = name.split(/[/\\]/).pop() ?? name;
|
||||
const dot = base.lastIndexOf('.');
|
||||
if (dot < 0 || dot === base.length - 1) return '';
|
||||
return base.slice(dot + 1).toLowerCase();
|
||||
}
|
||||
|
||||
/** Не FLAC и не WAV: в очереди на добавление такой файл нужно выделить. */
|
||||
export function needsFormatAttention(format: string | null | undefined, name: string): boolean {
|
||||
const ext = (format || audioExt(name)).toLowerCase();
|
||||
return ext !== '' && !PREFERRED_AUDIO.has(ext);
|
||||
}
|
||||
|
||||
export function isAudio(name: string): boolean {
|
||||
const ext = name.split('.').pop()?.toLowerCase() ?? '';
|
||||
return AUDIO_EXTENSIONS.includes(ext);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../../app/session';
|
||||
import { needsFormatAttention } from '../../features/uploads/files';
|
||||
import { useErrorText, useFieldError } from '../../shared/lib/errors';
|
||||
import { formatBytes, formatDateTime, formatDuration } from '../../shared/lib/format';
|
||||
import { checkIcon, closeIcon, editIcon, refreshIcon } from '../../shared/ui/icons';
|
||||
@@ -309,6 +310,11 @@ function QueueRow({
|
||||
<div className={u.meta}>
|
||||
<div className={u.flags}>
|
||||
<UploadStatusBadge status={f.status} />
|
||||
{needsFormatAttention(f.format, f.original_name) && (
|
||||
<span className={cx(s.badge, u.tone_warn)} title={t('uploads.format_attention')}>
|
||||
{t('uploads.flag_attention')}
|
||||
</span>
|
||||
)}
|
||||
{f.possible_duplicate && <span className={cx(s.badge, u.tone_warn)}>{t('uploads.flag_possible')}</span>}
|
||||
{f.quality_upgrade && <span className={cx(s.badge, u.tone_info)}>{t('uploads.flag_quality')}</span>}
|
||||
{f.matched_album_id ? (
|
||||
|
||||
@@ -10,6 +10,17 @@
|
||||
user-select: none; /* главная — только элементы управления волной */
|
||||
}
|
||||
|
||||
/* Плеер на всю ширину окна, а главная — в колонке справа от сайдбара.
|
||||
Сдвиг на половину сайдбара ставит волну на ту же ось, что и кнопка play.
|
||||
Если чипам не хватает места, сдвиг меньше, чтобы они не уехали под меню. */
|
||||
@media (min-width: 768px) {
|
||||
.home {
|
||||
transform: translateX(
|
||||
calc(-1 * min(var(--side-w, 240px) / 2, max(0px, (100% - 760px) / 2)))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.orbLabel {
|
||||
margin-top: 4px;
|
||||
font-family: var(--font-display);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect, useRef, useState, useSyncExternalStore, type DragEvent } fro
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../../app/session';
|
||||
import { AUDIO_EXTENSIONS, fromDrop, fromInput } from '../../features/uploads/files';
|
||||
import { AUDIO_EXTENSIONS, fromDrop, fromInput, needsFormatAttention } from '../../features/uploads/files';
|
||||
import { uploaderFor, type UploadItem } from '../../features/uploads/uploader';
|
||||
import { useErrorText } from '../../shared/lib/errors';
|
||||
import { formatBytes } from '../../shared/lib/format';
|
||||
@@ -81,7 +81,8 @@ export function SuggestPage() {
|
||||
>
|
||||
<Icon src={musicIcon} size={36} />
|
||||
<div className={u.dropTitle}>{t('uploads.drop_title')}</div>
|
||||
<div className={s.note}>{t('uploads.drop_hint', { formats: AUDIO_EXTENSIONS.join(', ') })}</div>
|
||||
<p className={u.formatWarn}>{t('uploads.drop_hint')}</p>
|
||||
<div className={s.note}>{t('uploads.drop_folders')}</div>
|
||||
<div className={u.dropActions}>
|
||||
<Button disabled={st.busy} onClick={() => filesRef.current?.click()}>
|
||||
{t('uploads.pick_files')}
|
||||
@@ -224,6 +225,7 @@ function FileRow({ item, locked, onRemove }: { item: UploadItem; locked: boolean
|
||||
<span style={{ width: `${item.progress * 100}%` }} />
|
||||
</div>
|
||||
)}
|
||||
{needsFormatAttention(null, item.relPath) && <span className={u.warn}>{t('uploads.format_attention')}</span>}
|
||||
{note && <span className={note.cls}>{note.text}</span>}
|
||||
</div>
|
||||
<span className={u.size}>{formatBytes(item.size, i18n.language)}</span>
|
||||
|
||||
@@ -25,6 +25,15 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.formatWarn {
|
||||
max-width: 640px;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.45;
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.dropActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
.shell {
|
||||
--side-w: 240px;
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 240px 1fr;
|
||||
grid-template-columns: var(--side-w) 1fr;
|
||||
grid-template-rows: 60px 1fr 88px;
|
||||
grid-template-areas:
|
||||
'top top'
|
||||
@@ -234,7 +235,7 @@
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.shell {
|
||||
grid-template-columns: 72px 1fr;
|
||||
--side-w: 72px;
|
||||
}
|
||||
|
||||
.navItem {
|
||||
@@ -269,6 +270,7 @@
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.shell {
|
||||
--side-w: 0px;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 60px 1fr 72px 64px;
|
||||
grid-template-areas:
|
||||
|
||||
@@ -29,7 +29,7 @@ type Item = {
|
||||
*/
|
||||
export function SearchBox({
|
||||
align = 'center',
|
||||
collapsible = true,
|
||||
collapsible = false,
|
||||
}: {
|
||||
align?: 'left' | 'center' | 'right';
|
||||
collapsible?: boolean;
|
||||
|
||||
Reference in new issue
Block a user