Этот файл является частью проекта Victor AI
Проект распространяется под лицензией GNU Affero General Public License v3.0 (AGPL-3.0).
Подробности лицензии: https://www.gnu.org/licenses/agpl-3.0.html
Полный текст: LICENSE.txt в корне репозитория.
Copyright © 2026 Olga Kalinina
Музыкальный плеер с AI-ассистированным подбором треков, фильтрацией по энергии/температуре и offline кешированием
Плейлист Victor AI — это интеллектуальный музыкальный плеер, который подбирает треки на основе настроения пользователя. Поддерживается ручной выбор, автоматический подбор через AI, фильтрация и статистика прослушиваний.
- AI-подбор треков - "Выбери сам" с reasoning в реальном времени
- Волны (Waves) - генерация плейлистов по энергии/температуре
- Фильтрация - по энергии и температуре треков
- Статистика - топ треков, общее время прослушиваний
- Моменты - история AI-подборов с reasoning
- Offline кэш - загрузка треков для прослушивания без интернета
- MediaSession - управление с lock screen и bluetooth
Для работы экрана необходима заполненная БД с треками на бэкенде.
📖 Гайд по заполнению БД
-
GET
/tracks- получение всех треков пользователя- Возвращает список треков с метаданными (энергия, температура)
- Поддержка пагинации через
offsetиlimit
-
GET
/tracks/stream/{track_id}- стриминг трека- Возвращает аудио поток для воспроизведения
-
GET
/tracks/stats- агрегированная статистика прослушиванийtotal_plays,top_tracks,top_energy,top_temperature,average_duration
-
GET
/tracks/playlist_moments- история AI-подборов- Список моментов с reasoning (stage1, stage2, stage3) и выбранным треком
-
POST
/tracks/choose_for_me- AI выбирает трек с streaming reasoning- Возвращает SSE stream с логами мыслительного процесса
- В конце отправляет выбранный трек
-
POST
/tracks/run_playlist_wave- генерация "волны" треков- Параметры:
energy,temperature - Возвращает список треков с указанными характеристиками
- Треки выбираются из помеченных пользователем
- Параметры:
- POST
/tracks/update_track_description- обновление энергии/температуры трека- Параметры:
track_id,energy_description,temperature_description
- Параметры:
![]() Главный экран плейлиста |
![]() Bottom sheet с треками |
![]() История моментов |
![]() AI подбор "Выбери сам" |
- Обзор
- Архитектура состояния
- Структура файлов
- Поток данных
- Streaming логи
- Фильтрация и сортировка
- Аудио плеер
- Лучшие практики
- Производительность
- Отладка
- Технологический стек
- Связанные документы
- Будущие улучшения
Плейлист Victor AI — это музыкальный плеер с AI-ассистированным подбором треков, поддержкой streaming логов, фильтрацией по энергии/температуре и offline кешированием.
- ✅ Единое состояние UI через
PlaylistUiState - ✅ Streaming логи от ассистента в реальном времени
- ✅ Реактивная фильтрация через
combine()flows - ✅ UI модель с предобработанными данными (
TrackUiModel) - ✅ Оптимизированные корутины (работают только при необходимости)
- ✅ Обработка ошибок с показом пользователю
- ✅ MediaSession для lock screen / bluetooth
- ✅ Offline кеширование треков
Всё живёт в одном объекте:
data class PlaylistUiState(
// 🎵 Треки и плеер
val tracks: List<TrackUiModel> = emptyList(),
val currentPlayingTrackId: Int? = null,
val isPlaying: Boolean = false,
val currentPosition: Float = 0f,
// 📊 Статистика
val stats: TrackStats? = null,
// 🔄 Состояния загрузки
val isLoading: Boolean = false,
val isWaveLoading: Boolean = false,
// ⚠️ Ошибки
val error: ErrorState? = null,
// 🔍 Фильтры (единая точка правды)
val energyFilter: String? = null,
val temperatureFilter: String? = null,
val sortBy: String = "recent",
val energyOptions: List<String> = emptyList(),
val temperatureOptions: List<String> = emptyList(),
// 🎵 Streaming логи
val streamingLog: String = ""
)ui/playlist/
├── PlaylistScreen.kt # Главный экран с markdown статистикой
├── PlaylistSheet.kt # Bottom sheet со списком треков
├── PlaylistViewModel.kt # Бизнес-логика и состояние
├── PlaylistUiState.kt # Модели состояния (UiState, TrackUiModel, ErrorState)
├── AmbientStreamInline.kt # Компонент для отображения streaming логов
├── PLAYLIST_ARCHITECTURE.md # Внутренняя документация
├── QUICK_REFERENCE.md # Краткая справка
│
└── components/
├── CurrentTrackPlayer.kt # Мини-плеер с прогресс-баром
├── TrackItem.kt # Элемент списка трека (обычный)
├── TrackItemWithCheckbox.kt # Элемент с чекбоксом (для выбора)
├── EditTrackSheet.kt # Bottom sheet для редактирования метаданных
└── PlaylistUtils.kt # Утилиты (formatDuration, цвета)
data/network/
├── MusicApi.kt # API интерфейс Retrofit
├── MusicApiImpl.kt # Реализация со streaming
└── Track, TrackStats, WaveResponse # DTOs
data/repository/
└── PlaylistRepository.kt # Репозиторий (опционально)
logic/
├── AudioPlayer.kt # Обёртка над ExoPlayer + MediaSession
└── MusicPlaybackService.kt # Foreground Service для уведомлений
┌─────────────┐
│ User opens │
│ PlaylistScreen │
└──────┬──────┘
│
▼
┌─────────────────┐
│ ViewModel.init()│
│ - loadTracks() │ ──┐
│ - loadStats() │ │
└─────────────────┘ │
│
▼
┌──────────────────┐
│ musicApi.getTracks() │
└──────┬───────────┘
│
▼
┌──────────────────┐
│ _rawTracks.value │ (List<Track> - DTO)
└──────┬───────────┘
│
▼
┌────────────────────────────┐
│ startUiStateUpdater() │
│ combine( │
│ _rawTracks, │
│ _trackCacheStates, │
│ _uiState │
│ ) { ... } │
└──────┬─────────────────────┘
│
▼
┌──────────────────┐
│ Track → TrackUiModel │ (Маппинг)
│ - formatDuration() │
│ - getEnergyColor() │
│ - getTemperatureColor() │
└──────┬───────────────┘
│
▼
┌──────────────────┐
│ Фильтрация │
│ + Сортировка │
└──────┬───────────┘
│
▼
┌──────────────────┐
│ _uiState.update │
│ { it.copy( │
│ tracks = ... │
│ )} │
└──────┬───────────┘
│
▼
┌──────────────────┐
│ UI collectAsState│
│ → Рекомпозиция │
└──────────────────┘
User clicks Play
│
▼
viewModel.playTrack(trackId)
│
├─→ Найти Track в _rawTracks
├─→ audioPlayer.updateTrackMetadata()
├─→ audioPlayer.playFromUrl(streamUrl)
├─→ _uiState.update { copy(currentPlayingTrackId, isPlaying=true) }
└─→ MusicPlaybackService.startPlayback() (Foreground Service)
│
└─→ MediaStyle notification с кнопками play/pause/next/prev
// ✅ Работает ТОЛЬКО при воспроизведении
_uiState.collectLatest { state ->
if (state.isPlaying) {
while (isActive && _uiState.value.isPlaying) {
delay(100)
val position = audioPlayer.getCurrentPosition()
_uiState.update { it.copy(currentPosition = position / 1000f) }
}
}
}User: "Выбери сам..."
│
▼
viewModel.runAssistantWaveStreaming(manual=true)
│
▼
musicApi.runPlaylistChainStreaming(accountId, "manual") { event ->
│
├─→ {"log": "🎵 анализирую..."}
│ └─→ _uiState.update { copy(streamingLog = "🎵 анализирую...") }
│ └─→ delay(типинг + чтение) // Даём время для анимации
│
├─→ {"log": "🎤 выбираю исполнителя..."}
│ └─→ _uiState.update { copy(streamingLog = "🎤 выбираю...") }
│ └─→ delay(типинг + чтение)
│
├─→ {"track": {"track_id": 123, ...}}
│ └─→ playTrack(123)
│
└─→ {"done": true}
└─→ delay(2000)
└─→ _uiState.update { copy(streamingLog = "") }
}
// PlaylistScreen.kt
LaunchedEffect(uiState.streamingLog) {
if (uiState.streamingLog.isNotEmpty()) {
showAmbientStream = true
typedText = ""
// Печатаем посимвольно
uiState.streamingLog.forEachIndexed { index, _ ->
delay(50) // 50мс между символами
typedText = uiState.streamingLog.take(index + 1)
}
} else {
showAmbientStream = false
}
}// ViewModel рассчитывает время показа каждого лога:
val typingTime = (logText.length * 50L).coerceAtLeast(1000L)
val readingTime = 1500L
delay(typingTime + readingTime)Пример:
- Лог "🎵 анализирую твоё настроение..." (37 символов)
- Время печати: 37 × 50мс = 1850мс
- Время чтения: 1500мс
- Итого: 3.35 секунды перед следующим логом
Симптом: Все логи приходят одним блоком в конце, а не по мере генерации.
Причина: Ngrok (даже платный) буферизует HTTP ответы, особенно если они приходят быстро.
Решения:
- На бэкенде (FastAPI):
async def jsonlines_stream():
async for item in builder.build_with_logs():
line = json.dumps(item, ensure_ascii=False) + "\n"
yield line
await asyncio.sleep(0) # 🔥 Даёт контроль event loop- На фронте:
// Добавляем задержки между логами
delay(typingTime + readingTime)- Альтернативные tunnels:
cloudflared(Cloudflare Tunnel) — лучше работает со streaminglocaltunnel— простой и без буферизации- Прямое подключение через VPN
Текущее состояние: Фронт компенсирует буферизацию задержками между логами.
Фильтры живут ТОЛЬКО в PlaylistUiState:
// Фильтры в ViewModel
data class PlaylistUiState(
val energyFilter: String? = null,
val temperatureFilter: String? = null,
val sortBy: String = "recent"
)
// UI просто вызывает методы:
viewModel.updateEnergyFilter("Высокая")Фильтрация происходит автоматически через combine():
combine(
_rawTracks, // Сырые данные с бэкенда
_trackCacheStates, // Состояния кеша
_uiState // Текущие фильтры
) { rawTracks, cacheStates, currentState ->
// 1. Маппинг Track → TrackUiModel
val tracksWithCache = rawTracks.map { track ->
track.toUiModel(cacheStates[track.id] ?: TrackCacheState.NOT_CACHED)
}
// 2. Фильтрация
val filtered = tracksWithCache.filter { track ->
(currentState.energyFilter == null ||
track.energyDescription == currentState.energyFilter) &&
(currentState.temperatureFilter == null ||
track.temperatureDescription == currentState.temperatureFilter)
}
// 3. Сортировка
val sorted = when (currentState.sortBy) {
"title" -> filtered.sortedBy { it.title }
"artist" -> filtered.sortedBy { it.artist }
"duration" -> filtered.sortedByDescending { it.duration }
else -> filtered.sortedByDescending { it.id }
}
// 4. Обновление UI state
currentState.copy(tracks = sorted, ...)
}.collect { newState ->
_uiState.value = newState
}При изменении фильтра → автоматически пересчитывается список → UI обновляется!
┌──────────────────────────┐
│ PlaylistViewModel │
├──────────────────────────┤
│ - playTrack() │
│ - pauseTrack() │
│ - resumeTrack() │
│ - seekTo() │
└───────┬──────────────────┘
│
▼
┌──────────────────────────┐
│ AudioPlayer │ (logic/AudioPlayer.kt)
├──────────────────────────┤
│ - ExoPlayer │
│ - MediaSession │
│ - WakeLock / WiFiLock │
│ - Audio Focus │
└───────┬──────────────────┘
│
├─→ Callbacks:
│ - onCompletion → playNextTrack()
│ - onPlayPause → update _isPlaying
│ - onNext → playNextTrack()
│ - onPrevious → playPreviousTrack()
│
└─→ MediaSession callbacks:
- onPlay / onPause / onNext / onPrevious / onSeekTo
// AudioPlayer инициализирует MediaSession
private fun initMediaSession() {
mediaSession = MediaSessionCompat(context, "VictorAI_MediaSession").apply {
setCallback(object : MediaSessionCompat.Callback() {
override fun onPlay() { resume() }
override fun onPause() { pause() }
override fun onSkipToNext() { onNextCallback?.invoke() }
override fun onSkipToPrevious() { onPreviousCallback?.invoke() }
override fun onSeekTo(pos: Long) { seekTo(pos.toInt()) }
})
setFlags(
FLAG_HANDLES_MEDIA_BUTTONS or
FLAG_HANDLES_TRANSPORT_CONTROLS
)
isActive = true
}
}
// Обновление метаданных
fun updateTrackMetadata(title: String, artist: String, duration: Long) {
mediaSession?.setMetadata(
MediaMetadataCompat.Builder()
.putString(METADATA_KEY_TITLE, title)
.putString(METADATA_KEY_ARTIST, artist)
.putLong(METADATA_KEY_DURATION, duration)
.build()
)
}// PlaylistViewModel запускает service при воспроизведении
MusicPlaybackService.startPlayback(
context = applicationContext,
trackTitle = track.title,
trackArtist = track.artist,
isPlaying = true,
sessionToken = audioPlayer.getMediaSessionToken(),
duration = track.duration.toLong(),
position = 0
)Результат:
- ✅ Управление с lock screen
- ✅ Управление с bluetooth гарнитуры
- ✅ Воспроизведение в фоне
- ✅ MediaStyle уведомление
// ✅ Единое состояние
val uiState by viewModel.uiState.collectAsState()
// ✅ UI модель с предобработкой
data class TrackUiModel(
val formattedDuration: String, // Не Float
val artist: String, // Не nullable
val energyColor: Color // Готовый цвет
)
// ✅ Логика в ViewModel
viewModel.updateEnergyFilter("Высокая")
// ✅ UI локальные состояния только для UI
var showDropdown by remember { mutableStateOf(false) }
// ✅ Key в LazyColumn
items(tracks, key = { it.id }) { track -> ... }
// ✅ Обработка ошибок
uiState.error?.let { error ->
ErrorCard(error = error, onDismiss = { viewModel.clearError() })
}-
Корутины работают только при необходимости
- Position updater: только при
isPlaying = true - Notification updater: реактивный через
combine()
- Position updater: только при
-
Фильтрация один раз в ViewModel
- Не пересчитывается при каждой рекомпозиции
- Результат кешируется в
uiState.tracks
-
LazyColumn с key
- Эффективная рекомпозиция только изменённых элементов
-
UI модель с предобработкой
- Форматирование один раз при маппинге
- Не пересчитывается при рендере
-
remember для вычислений
val editingTrack = remember(editingTrackId, uiState.tracks) { editingTrackId?.let { id -> uiState.tracks.firstOrNull { it.id == id } } }
Включены детальные логи:
# Фильтр для всех логов плейлиста
adb logcat -s PlaylistViewModel:D MusicApiImpl:D AudioPlayer:D
# Или в Android Studio Logcat:
tag:PlaylistViewModel OR tag:MusicApiImpl OR tag:AudioPlayer🎵 Starting streaming wave- Начало streaming запроса📡 Response received- Получен HTTP ответ📝 Stream log- Получен лог из stream🎧 Stream received track- Получен трек✅ Stream completed- Stream завершён❌ Error- Ошибка
- Jetpack Compose - современный UI toolkit
- Material 3 - дизайн-система
- LazyColumn - эффективный рендеринг списков
- BottomSheet - модальные окна для треков и редактирования
- MVVM - Model-View-ViewModel паттерн
- Hilt - Dependency Injection
- Kotlin Coroutines - асинхронность
- StateFlow - reactive state management
- combine() - реактивная фильтрация и трансформация
- ExoPlayer - мощный аудио/видео плеер от Google
- MediaSession - интеграция с системными медиа-контролами
- AudioManager - управление audio focus
- WakeLock / WiFiLock - предотвращение засыпания при воспроизведении
- Retrofit + Moshi - HTTP клиент и JSON
- SSE (Server-Sent Events) - стриминг reasoning от AI
- OkHttp - низкоуровневый HTTP клиент
- UI модель (TrackUiModel) - предобработка данных
- remember - мемоизация вычислений
- key в LazyColumn - эффективная рекомпозиция
- Conditional coroutines - работают только при необходимости
- Foreground Service - воспроизведение в фоне
- MediaStyle Notification - системное уведомление с кнопками
- places.md - Экран "Места" с игровой картой
- calendar.md - Экран "Расписание"
- chat.md - Экран "Чат"
- README.md - Обзор всей документации
- Создание пользовательских плейлистов
- Экспорт/импорт плейлистов
- Lyrics display (синхронизированные тексты)
- Эквалайзер с пресетами
- Sleep timer (таймер сна)
- Кроссфейд между треками
- Скробблинг в Last.fm / ListenBrainz
- Визуализация аудио (waveform)
- Социальные функции (делиться треками)
- Умные плейлисты на основе истории



