Что будет, если соединить любовь к музыке и к разработке? В моем случае получился pet-проект по созданию музыкального стримингового веб-приложения. Меня зовут Андрей, я работаю frontend-разработчиком в компании «КОРУС Консалтинг». И сейчас я расскажу, как стоит подходить к разработке pet-проектов, чтобы учиться эффективнее.
Понятно, что не все навыки можно освоить через рабочие задачи, в таком случае нам на помощь и приходят pet-проекты. Они дают возможность не только попробовать новое, но и экспериментировать с идеями и технологиями без ограничений.
Если смотреть на pet-проект не как на лабораторную работу, а как на будущий продукт, начинаешь лучше понимать весь цикл разработки. При таком подходе ты сперва выступаешь в роли заказчика: определяешь, какую задачу пользователя решаешь, как это сделать наилучшим образом, а потом подбираешь технологии, которыми легче всего реализовать функционал или которые хочется попробовать.
Продуктовый подход позволяет взглянуть на процессы в разработке в миниатюре: понять на собственном примере, откуда появляются таски, как их приоритизировать. Начинаешь задаваться вопросами, как построить архитектуру и какой технологический стек выбрать, чтобы было легко добавлять запланированный функционал (учитывая, что с ростом приложения растет и желание прикручивать новый функционал).
Далее расскажу о развитии моего pet-проекта и чему удалось научиться, используя данный подход.
О проекте
На разработку я вдохновился музыкальными стриминговыми приложениями вроде Spotify, Яндекс Музыки и т.д. Решил попробовать реализовать свой аналог, т.к. тема музыки мне близка и хотелось поработать с музыкальными файлами, используя возможности браузера. Хотелось создать свой zaycev.net.
На данном этапе важно сформировать требования к проекту так, будто у вас есть заказчик (даже если это вы сами). Требования помогут сформировать план работы, сфокусироваться на реализации функционала, который интересен. Таким образом вы создаете обязательство с самим собой завершить проект.
Так как мы смотрим на pet-проект как на прототип продукта, хорошо бы подумать, какие люди могли бы им воспользоваться. Мне хотелось, чтобы потенциальным пользователем приложения мог стать любой человек, и сообщество пользователей могло расширять общую музыкальную библиотеку. Также приложение целится на инди-артистов, которые потенциально смогут найти новых слушателей и бесплатно продвигать свои треки.
Для своего проекта я сформировал следующие требования:
Юзабилити
Реализация интерфейса с использованием собственной библиотеки UI компонентов (далее UI kit).
Реализовать функциональный плеер с возможностью drag and drop управления громкостью, процессом воспроизведения, кнопками переключения на предыдущий или следующий трек и паузы.
Добавить возможность кастомизации интерфейса под желания пользователя, наличие смены языка, темной/cветлой темы.
ТехническиеПроигрывание музыкальных треков и радио на собственном музыкальном плеере, реализованном на базе Web Audio API.
Проверка пользователя на «человечность» при регистрации путем отправки e-mail с кодом подтверждения.
Настройка CI/CD так, чтобы при добавлении новых фич ранние пользователи сразу могли их видеть.
ПользовательскиеДать возможность пользователям прослушивать радио со всего мира.
Предоставить возможность сообществу пользователей самостоятельно расширять библиотеку треков.
Добавить возможность делиться приложением в соцсетях и мессенджерах.
Возможность искать треки по исполнителю, названию, радио по названию, стране, жанрам, поисковик должен подсказывать при вводе.
Возможность добавлять треки и радио в избранные.
Добавить возможность скачивать музыкальные треки на компьютер.
Возможность сброса пароля с отправкой e-mail с кодом сброса.
Проектируем архитектуру
После описания концепции и сбора требований, описываем архитектуру будущего приложения. В моем случае она выглядит так:
Выбор технологического стека
Frontend: для реализации клиентской стороны приложения выбрал наиболее знакомый стек технологий React, Redux Tool Kit, Typesript, Webpack.
Затем решил поэкспериментировать и вместо Webpack использовать Vite, т.к. HMR ускорит разработку, а конфигурацию сборщика можно написать за пару минут. Для добавления мультиязычности использовал i18next. Для работы со стилями выбрал SCSS, также рассматривал Tailwind, но т.к. я хотел описать общую дизайн-систему приложения, глобальные переменные и удобная работа со вложенностью определили мой выбор.
UI kit: в случае с библиотекой компонентов решил остановиться на Storybook, React, Typesript, SCSS, Webpack. Ключевым решением был именно выбор сборщика, т.к. до этого библиотеки я ранее не разрабатывал, а с Webpack нашел много материалов и примеров кода.
Backend: Тут я долго выбирал между Express.js и Nest.js. В Nest.js мне нравился ОПП подход, а Express.js тем, что можно быстро написать простенький сервер, и у меня уже был небольшой опыт работы с ним. В итоге решил остановиться на Express.js. Также было очевидно, что для хранения треков понадобится облако (с которым я ранее не работал). В результате небольшого поиска наткнулся на библиотеку easy-yandex-s3, которая дает возможность легкого взаимодействия с облаком. Для авторизации решил использовать подход sessions+cookie. Для работы с файлами использовал multer, мне понравилась простота документации и возможностью хранить файл в оперативной памяти перед отправкой на облако.
Общение между клиентом и сервером должно было проходить через REST API.
Проектирование технического дизайна
Описываем взаимодействие компонентов интерфейса и таблицы в базе данных.
Проектирование пользовательского дизайна
К сожалению, я имею лишь поверхностные знания о UX/UI дизайне и не могу создать пользовательский интерфейс с нуля, поэтому я нашел готовый подходящий дизайн в интернете и реализовал его.
Реализация функционала
В этой части я бы не хотел досконально описывать все шаги по разворачиванию приложения, поэтому ограничусь описанием, на мой взгляд, более интересного функционала. При этом, чтобы сделать статью более полной и полезной для новичков, в конце я прикрепил ссылки на репозитории с исходным кодом проекта.
Так выглядит, например, реализация контроллера Express для загрузки треков в облако с помощью транзакции Sequelize. Сначала вызываю сервис загрузки в облако, затем сервис записи в БД, затем делаю коммит, чтобы сохранить изменения. В случае возникновения ошибки все изменения откатятся, данные будут консистентны, и операция будет атомарной.
tracksController.post(
'/uploadTrack',
authChecker,
upload.fields([
{ name: 'cover', maxCount: 1 },
{ name: 'track', maxCount: 1 }
]),
async (req, res, next) => {
const t = await sequelize.transaction()
try {
const { trackName, artist } = req.body
const cover = req.files?.cover[0]?.buffer
const track = req.files?.track[0]?.buffer
const { img, mp3 } = await cloudService.upload({
track,
cover
})
await tracksService.addTrack({
artist,
trackName,
img,
mp3,
moderated: false
})
await t.commit()
res.sendStatus(200)
} catch (e) {
await t.rollback()
next(e)
}
}
)
const EasyYandexS3 = require('easy-yandex-s3').default
require('dotenv').config()
const s3 = new EasyYandexS3({
auth: {
accessKeyId: process.env.ACCESS_KEY_ID,
secretAccessKey: process.env.SECRET_ACCESS_KEY
},
Bucket: 'nirvana-tracks',
debug: false
})
async function upload({ track, cover }) {
try {
let mp3 = await s3.Upload(
{
buffer: track
},
'/mp3/'
)
let img = await s3.Upload(
{
buffer: cover
},
'/img/'
)
if (mp3 && track) {
return { img: img.Location, mp3: mp3.Location }
} else {
throw new Error('Couldn`t upload')
}
} catch (e) {
console.error(e)
throw e
}
}
module.exports = {
upload
}
Чтобы быть более независимым от библиотеки RadioBrowser как от стороннего API, я решил один раз сделать запрос на все радиостанции библиотеки и наполнить данными свою БД как seeders. Тут же проверял, что ссылка на обложку валидна.
const RadioBrowser = require('radio-browser')
const { v4: uuidv4 } = require('uuid')
const checkImage = require('../../web/utils/checkImage')
module.exports = {
async up(queryInterface, Sequelize) {
const radios = await RadioBrowser.getStations({ limit: 100 })
const radiosWithUsefullFields = await Promise.all(
radios.map(async el => {
return {
id: uuidv4(),
name: el.name,
url: el.url,
votes: el.votes,
country: el.country,
favicon: (await checkImage(el.favicon)) ? el.favicon : '',
tags: el.tags,
lastcheckoktime: el.lastcheckoktime
}
})
)
await queryInterface.bulkInsert('Radios', radiosWithUsefullFields, {})
},
async down(queryInterface, Sequelize) {
await queryInterface.bulkDelete('Radios', null, {})
}
}
async function checkImage(url) {
return fetch(url)
.then(response => {
if (response.ok) {
return true
} else {
return false
}
})
.catch(error => {
return false
})
}
module.exports = checkImage
Опишу реализацию кастомного плеера. По сути, с помощью useRef – взял ссылку на audio элемент и получил доступ к Web Audio API. Дальше связал данные из Audio API с состоянием React, чтобы вызывать рендеры, таким подходом начал расширять функционал, переключение треков, drag & drop перемотку, регулировку звука, паузу и т.д.
return (
<>
<audio
src={currentTrack?.url}
ref={audioElem as LegacyRef<HTMLAudioElement>}
onTimeUpdate={() => {
onPlaying({
audioElem:
audioElem as MutableRefObject<HTMLAudioElement>,
setCurrentTrack,
currentTrack
})
}}
/>
Пример реализации кастомного хука, который автоматически запускает трек при выборе или переключении трека.
import { useLayoutEffect } from 'react'
import { usePlayOnMountArgs } from './types'
export function usePlayOnMount({
tracks,
setCurrentTrack,
position,
audioElem,
setIsPlaying
}: usePlayOnMountArgs) {
useLayoutEffect(() => {
setCurrentTrack(tracks[position])
const timeoutId = setTimeout(() => {
audioElem?.current?.play()
setIsPlaying(true)
}, 500)
return () => {
clearTimeout(timeoutId)
}
}, [tracks, position])
}
Решил чуть-чуть дополнить макет, чтобы пользователь чувствовал погружение в UX при прослушивании, и название приложения (Nirvana) оправдывало себя.
Для этого сделал размытую дымку на фоне плеера в цветах обложки трека, которая переливается. А если обложки по каким-то причинам нет, то дымка в цветах приложения. Такой эффект реализовал так:
<div
className={styles.playerBg}
style={{
backgroundImage:
currentTrack.img && `url(${currentTrack.img})`
}}
></div>
@import '../../constants/colors.scss';
.playerBg {
z-index: 0;
height: 20%;
width: 120vw;
opacity: 0.85;
position: fixed;
bottom: -5%;
left: -5vw;
background-image: linear-gradient(
90deg,
rgba(243, 243, 243, 1) 0%,
rgba(94, 233, 191, 1) 19%,
rgba(47, 105, 255, 1) 54%,
rgba(99, 96, 255, 1) 82%,
rgba(161, 106, 232, 1) 100%
);
filter: blur(30px);
background-repeat: no-repeat;
background-size: 300% 300%;
animation: AnimateBG 100s ease-in-out infinite;
}
@keyframes AnimateBG {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
CI/CD
Автотестов для приложения у меня не было. Вместо этого я отправил ссылку на проект всем друзьям и знакомым, чтобы они дали фидбек и помогли отловить баги. Чтобы ускорить этот процесс я решил написать простой CI pipeline с помощью GitHub Actions.
name: onPush
run-name: Actions on push
on: [push]
jobs:
init:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [21.x]
steps:
- uses: actions/checkout@v3
- name: Staring Node.js ${{matrix.node-version}}
uses: actions/setup-node@v3
with:
node-version: ${{matrix.node-version}}
- name: install modules
run: npm install
- name: install prettier
run: npm install --global prettier
- name: formatting code
run: prettier . -w
- name: build project
run: npm run build
Деплоить решил на Render, мне понравилось, что там можно легко развернуть БД, бэкенд и клиент, также Render сам будет тянуть изменения из репозитория GitHub.
С какими сложностями я столкнулся
Основная сложность возникла с авторскими правами. Пришлось сделать раздел радио доступным только админу, также загруженные треки добавляются в общую ленту только после подтверждения администратора (пока подтверждать можно только через БД).
Еще было не совсем очевидно, почему иногда при частой смене play и pause браузерное API выкидывало ошибку «The play() request was interrupted by a call to pause()». Но это удалось пофиксить кастомным хуком для debounce и соответствующей функцией из lodash. Ниже мое решение этой проблемы.
import { useEffect } from 'react'
import { useDebounceOnPlayPauseArgs } from './types'
export function useDebounceOnPlayPause({
audioElem,
isPlaying
}: useDebounceOnPlayPauseArgs) {
useEffect(() => {
const timeoutId = setTimeout(() => {
if (isPlaying) {
audioElem?.current?.play()
} else {
audioElem?.current?.pause()
}
}, 500)
return () => {
clearTimeout(timeoutId)
}
}, [isPlaying])
}
Плюс достаточно времени ушло на мобильную адаптацию, от части функционала пришлось отказаться, чтобы не перегружать UI. В какой-то момент я решил использовать минимум кнопок и сделать фокус на свайпах. Так, например, ряд треков можно листать свайпами и также взаимодействовать с каруселью. Это я реализовал с помощью react-swipeable. На финальных этапах работы над проектом обращался к друзьям фронтам и бекендерам node js, перед деплоем попросил их поревьюить код и внес правки. Свежий взгляд позволил лучше декомпозировать логику. После деплоя друзья указали мне на баги и дали советы по улучшению UX.
Что с проектом сейчас?
Проект доступен в сети в мобильной и десктопной версии и выглядит так.
Также проект показывает достаточно хороший performance по метрикам Lighthouse
Исходный код выложен в открытый доступ на GitHub, может посмотреть любой желающий
https://github.com/ABurov30/nirvana-ui
https://github.com/ABurov30/nirvana-client
https://github.com/ABurov30/nirvana-server
UI kit опубликован в npm.
https://www.npmjs.com/package/nirvana-uikit
Делаем выводы
Благодаря этому pet-проекту я побывал одновременно в роли заказчика и исполнителя, попробовал применить продуктовый подход и самостоятельно выстроить процесс разработки приложения с нуля. Научился определять стек технологий, поработал с облаком, Web Audio Api, разработал свою UI библиотеку и попробовал новые технологии.
В будущем я планирую на базе этого проекта пробовать новые технологии, добавляя функционал в приложении. Например, хочу сделать микрофронты с тремя проектами:
основное музыкальное приложение Nirvana на React, Vite, Redux
главная промо-страница приложения на Next.js.
чат внутри приложения на Vue, Socket io, Graph Ql (возможно возьму какой-нибудь неочевидный стейт менеджер типа jotai или zustand).
Ну и прикручивать автоматическую валидацию авторских прав, потому что проверять руками загруженный пользователями контент достаточно сложно, хочется автоматизировать этот процесс.