feat(mobile): Skia wave, wave player view, themes and playback settings (M5)
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m30s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 2m57s
ci / ts / ts (packages + web) (push) Successful in 1m16s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 33s

- My Wave is rendered with react-native-skia on the UI thread; physics
  and colour helpers moved to @pipu/core (wave-anim) and shared with web.
- Full-screen player honours ui_prefs.mobile_player (cover / wave).
- Theme switching (dark / light / system) via a theme context and
  useColors() across all screens.
- Profile: theme, wave variant, player view, sound quality sheet
  (Wi-Fi / mobile / downloads), recent-tracks cache clearing on next
  cold start, and a change-password sheet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 13:30:21 +03:00
1 parent 756e915ff9
commit ed063d3763
28 files changed
+923 -188

No files matched your search

+1
View File
@@ -22,3 +22,4 @@ export {
type Track,
} from './player';
export { checkServer, normalizeServerUrl, SERVER_CHECK_TIMEOUT_MS, type ServerCheck } from './server';
export { initialWaveState, mix, rgb, rgba, step, WAVE_COLORS, waveScale, type Rgb, type WaveState } from './wave-anim';
+85
View File
@@ -0,0 +1,85 @@
// Общие формулы кнопки «Моя волна» (docs/design.md#3): состояние, пружины, пульс в такт BPM и цвета.
// Отрисовка — своя на платформе: Canvas 2D в вебе, Skia на Android. Директива 'worklet' нужна Android,
// где кадр считается в UI-потоке Reanimated; в вебе это просто строка.
export type Rgb = [number, number, number];
export interface WaveState {
hover: number;
hoverT: number;
press: number;
pressV: number;
pressT: number;
mx: number;
my: number;
ang: number;
t: number;
amp: number;
pulse: number;
speed: number;
phase: number;
variant: 'orb' | 'liquid';
playing: boolean;
bpm: number;
energy: number;
colors: [string, string, string];
dark: boolean;
reduced: boolean;
size: number;
}
export const WAVE_COLORS: [string, string, string] = ['#2F80FF', '#7C5CFF', '#35D3FF'];
export function initialWaveState(size: number): WaveState {
return {
hover: 0, hoverT: 0, press: 0, pressV: 0, pressT: 0, mx: 0, my: 0, ang: 0, t: 0, amp: 0, pulse: 0,
speed: 1, phase: 0, variant: 'orb', playing: false, bpm: 100, energy: 0.6, colors: WAVE_COLORS,
dark: true, reduced: false, size,
};
}
export function rgb(hex: string): Rgb {
'worklet';
const h = hex.replace('#', '');
const full = h.length === 3 ? h[0]! + h[0]! + h[1]! + h[1]! + h[2]! + h[2]! : h;
const n = parseInt(full, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
export function rgba(c: Rgb, a: number): string {
'worklet';
return `rgba(${c[0]},${c[1]},${c[2]},${a})`;
}
export function mix(a: Rgb, b: Rgb, k: number): Rgb {
'worklet';
return [
Math.round(a[0] + (b[0] - a[0]) * k),
Math.round(a[1] + (b[1] - a[1]) * k),
Math.round(a[2] + (b[2] - a[2]) * k),
];
}
/** Шаг физики: пружины hover / press, амплитуда при игре, пульс в такт BPM (docs/design.md#3). */
export function step(s: WaveState, dt: number) {
'worklet';
s.hover += (s.hoverT - s.hover) * 0.08;
s.pressV += (s.pressT - s.press) * 0.35;
s.pressV *= 0.62;
s.press += s.pressV;
s.amp += ((s.playing ? 1 : 0) - s.amp) * 0.04;
s.speed = s.reduced ? 0.15 : 1 + s.hover * 1.4 + s.amp * 0.7;
s.t += dt * s.speed;
if (s.playing && !s.reduced) {
s.phase = (s.phase + (dt * s.bpm) / 60) % 1;
s.pulse += (Math.pow(1 - s.phase, 4) * (0.35 + s.energy * 0.65) - s.pulse) * 0.5;
} else {
s.pulse += ((s.reduced ? 0 : (Math.sin(s.t * 1.1) * 0.5 + 0.5) * 0.12) - s.pulse) * 0.1;
}
}
/** Масштаб круга: сжатие при нажатии, лёгкое увеличение при наведении. */
export function waveScale(s: WaveState): number {
'worklet';
return 1 - s.press * 0.07 + s.hover * 0.02;
}
+16 -1
View File
@@ -231,6 +231,21 @@
"dl_retry": "Retry failed",
"dl_empty": "Download tracks, albums and likes to listen without a connection",
"dl_pending_few": "{{count}} tracks queued",
"dl_pending_many": "{{count}} tracks queued"
"dl_pending_many": "{{count}} tracks queued",
"player_view": "Player view",
"player_cover": "Cover",
"player_wave": "Wave",
"quality": "Sound quality",
"quality_wifi": "On Wi-Fi",
"quality_cellular": "On mobile data",
"quality_download": "Downloads",
"quality_hint": "The new quality applies to the next tracks; downloaded tracks are not re-downloaded.",
"q_original": "Original",
"q_high": "High",
"q_medium": "Medium",
"q_low": "Low",
"cache": "Recent tracks cache",
"cache_clear": "Clear ({{size}} MB)",
"cache_pending": "Will be cleared on next launch"
}
}
+16 -1
View File
@@ -231,6 +231,21 @@
"dl_waiting_wifi": "Загрузки ждут Wi-Fi ({{count}})",
"dl_waiting_network": "Загрузки ждут сеть ({{count}})",
"dl_retry": "Повторить неудачные",
"dl_empty": "Скачивайте треки, альбомы и лайки — они будут доступны без сети"
"dl_empty": "Скачивайте треки, альбомы и лайки — они будут доступны без сети",
"player_view": "Вид плеера",
"player_cover": "Обложка",
"player_wave": "Волна",
"quality": "Качество звука",
"quality_wifi": "По Wi-Fi",
"quality_cellular": "По мобильной сети",
"quality_download": "Загрузки",
"quality_hint": "Новое качество применяется к следующим трекам; уже скачанные не перекачиваются.",
"q_original": "Оригинал",
"q_high": "Высокое",
"q_medium": "Среднее",
"q_low": "Низкое",
"cache": "Кэш недавних треков",
"cache_clear": "Очистить ({{size}} МБ)",
"cache_pending": "Очистится при следующем запуске"
}
}