Что будет, если соединить любовь к музыке и к разработке? В моем случае получился 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 (
<>
Пример реализации кастомного хука, который автоматически запускает трек при выборе или переключении трека.
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) оправдывало себя.
Для этого сделал размытую дымку на фоне плеера в цветах обложки трека, которая переливается. А если обложки по каким-то причинам нет, то дымка в цветах приложения. Такой эффект реализовал так:
@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).
Ну и прикручивать автоматическую валидацию авторских прав, потому что проверять руками загруженный пользователями контент достаточно сложно, хочется автоматизировать этот процесс.