Логотип Корус Консалтинг

Продуктовый подход к pet-проекту или как я разработал музыкальное веб-приложение

Что будет, если соединить любовь к музыке и к разработке? В моем случае получился 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>}
               onTimeUpd ate={() => {
                   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).

Ну и прикручивать автоматическую валидацию авторских прав, потому что проверять руками загруженный пользователями контент достаточно сложно, хочется автоматизировать этот процесс. 

Твое резюме отправлено

Мы сохраним его в базе.
Если у нас появится подходящая вакансия, мы обязательно тебе напишем.

Ошибка при отправке