Skip to content

Пример 002: Интеграция с Web API

Обратите внимание на жизненный цикл компонента: очистка и освобождение системных ресурсов плеера.

tsx
import { useEffect } from 'react'
import baseClasses from '~/ui.common.module.scss'
import btnClasses from '~/ui.button.module.scss'
import { ReactiveEngine, useReactiveValue } from '@pravosleva/reactive-engine'
import clsx from 'clsx'
import { AudioPlayerLogic } from './service.AudioPlayerLogic'

const engine = new ReactiveEngine()

export const AudioPlayerExample = () => {
  const logic = engine.inject(AudioPlayerLogic)
  const { loading, error } = useReactiveValue(logic.audioResource)
  const currentTrackId = engine.use(logic.currentTrackId)
  const isPlaying = engine.use(logic.isPlaying)

  useEffect(() => {
    return () => {
      logic.destroy()
    }
  }, [logic])

  const activeTrack = logic.tracks.find(t => t.id === currentTrackId)

  return (
    <div
      className={clsx(baseClasses.unit, baseClasses.stack2)}
      style={{ fontFamily: 'system-ui', minWidth: '400px' }}
    >
      <div className={baseClasses.absoluteUnitLabel}>Simple Audio Player (DI + Cache)</div>

      <div className={baseClasses.stack2}>
        {logic.tracks.map((track) => {
          const isSelected = track.id === currentTrackId
          const isThisTrackError = isSelected && !!error

          return (
            <div
              key={track.id}
              onClick={() => logic.selectTrack(track.id)}
              style={{
                padding: '12px',
                borderRadius: '8px',
                cursor: 'pointer',
                border: isThisTrackError ? '2px solid #ef5350' : isSelected ? '2px solid #1a73e8' : '2px solid lightgray',
                display: 'flex',
                justifyContent: 'space-between',
                gap: '8px',
                alignItems: 'center',
                transition: 'all 0.2s ease'
              }}
            >
              <b>{track.title}</b>
              {
                isThisTrackError
                  ? <span>Ошибка загрузки 🔴</span>
                  : isSelected && loading
                    ? <span>Загрузка... 🟡</span>
                    : isSelected
                      ? <span>🟢</span>
                      : <span>⚪</span>
              }
            </div>
          )
        })}
      </div>

      {activeTrack && (
        <div className={baseClasses.stack2}>
          <div style={{ textAlign: 'left' }}>
            Выбрано: <b>{activeTrack.title}</b>
          </div>

          {error && (
            <div style={{ color: '#ef5350', width: '100%', textAlign: 'center' }}>
              Error: {error.message}
            </div>
          )}

          {
            !error && (
              <div style={{ display: 'flex', gap: '16px', justifyContent: 'center', width: '100%' }}>
                <button
                  disabled={loading || !!error || isPlaying}
                  onClick={() => logic.play()}
                  className={clsx(
                    btnClasses.btn,
                    btnClasses.neonBtn,
                    btnClasses['neonBtn--primary'],
                    {
                      [btnClasses['neonBtn--outlined']]: !isPlaying,
                      [btnClasses['neonBtn--contained']]: isPlaying
                    },
                  )}
                  style={{ opacity: loading || !!error || isPlaying ? 0.5 : 1 }}
                >
                  Play
                </button>

                <button
                  disabled={!isPlaying}
                  onClick={() => logic.stop()}
                  className={clsx(
                    btnClasses.btn,
                    btnClasses.neonBtn,
                    {
                      [btnClasses['neonBtn--primary']]: isPlaying,
                      [btnClasses['neonBtn--secondary']]: !isPlaying,
                    },
                    btnClasses['neonBtn--outlined']
                  )}
                  style={{ opacity: !isPlaying ? 0.5 : 1 }}
                >
                  Stop
                </button>
              </div>
            )
          }
        </div>
      )}
    </div>
  )
}

Зачем нужен этот useEffect в этом коде?

  1. Предотвращение утечек памяти (Memory Leaks): Браузерный объект AudioContext является тяжелой системной сущностью, которая удерживает аудиовыходы устройства. Метод logic.destroy() принудительно закрывает контекст (context.close()) и отвязывает нативные узлы звука (audioSourceNode.disconnect()) в момент ухода компонента с экрана.
ts
export class AudioPlayerLogic extends AbstractService { 
  // ...
  public stop() { 
    if (this.audioSourceNode) {
      try {
        this.audioSourceNode.stop()
      } catch (e) {
        // Игнорируем ошибки, если нода уже завершила работу
      }
      this.audioSourceNode.disconnect() 
      this.audioSourceNode = null
    }

    this.isPlaying.value = false
  }
  public destroy() { 
    this.stop() 
    if (this.audioContext) {
      this.audioContext.close() 
      this.audioContext = null
    }
    this.currentTrackId.value = null
    this.audioCache.clear()
  }
}
  1. Очистка оперативной памяти (Heap): Метод очищает накопительный кэш audioCache.clear(). Без этого декодированные бинарные массивы AudioBuffer всех прослушанных треков остались бы замороженными в памяти приложения навсегда, так как Garbage Collector не может удалить их при наличии живых ссылок внутри синглтон-сервиса AudioPlayerLogic.
  2. Безопасность рантайма: Сброс реактивных сигналов в null гарантирует, что при повторном открытии этой страницы плеер стартует с чистого, предсказуемого состояния (без зависших ID треков).

Метод engine.inject(AudioPlayerLogic) возвращает стабильную ссылку на синглтон-сервис, которая никогда не меняется на протяжении всей жизни приложения. Поскольку ссылка logic стабильна, сам хук useEffect при обычном изменении реактивных сигналов и рендерах компонента выполняться повторно не будет.

Соответственно, cleanup-функция...

tsx
useEffect(() => {
  return () => {
    logic.destroy()
  }
}, [logic])

...выполнится ровно один раз — в момент полного размонтирования (Unmount) React-компонента с экрана. Произойдет это в этих случаях:

  • Переключение страниц / вкладок (Routing)
  • Условный рендеринг (Conditional Rendering) в родителе
  • Горячая перезагрузка при разработке (Vite Hot Reload / Fast Refresh)

Таким образом, массив зависимостей [logic] здесь играет роль предохранителя, который сообщает React: «Следи за сервисом. Пока сервис тот же, ничего не делай. Но если компонент вообще исчезнет из интерфейса — обязательно вызови деструктор плеера»

Логика плеера вынесена в отдельный класс (сервис):

ts
import { AbstractService } from '@pravosleva/reactive-engine'

export interface Track {
  id: string
  title: string
  url: string
}

const TRACK_LIST: Track[] = [
  {
    id: '1',
    title: 'Что Где когда - Пауза 1',
    url: 'https://pravosleva.pro/d/sounds-in-access-2025/static/projects/what-where-when/audio/pause1.mp3',
  },
  {
    id: '2',
    title: 'Что Где когда - Пауза  3',
    url: 'https://pravosleva.pro/d/sounds-in-access-2025/static/projects/what-where-when/audio/pause3.mp3',
  },
  {
    id: '3',
    title: 'Incorrect URL',
    url: 'https://gdebenzin.app',
  }
]

export class AudioPlayerLogic extends AbstractService {
  public tracks: Track[] = TRACK_LIST

  // Реактивные сигналы состояния для UI
  public currentTrackId = this.createSignal<string | null>(null, 'player:signal:track-id')
  public isPlaying = this.createSignal<boolean>(false, 'player:signal:is-playing')

  // Накопительный кэш аудио-буферов
  private audioCache = new Map<string, AudioBuffer>()

  // Инфраструктурные объекты Web Audio API
  private audioContext: AudioContext | null = null
  private audioSourceNode: AudioBufferSourceNode | null = null

  /**
   * Реактивный ресурс загрузки аудио.
   * Отвечает за реактивные статусы loading/error в UI при смене currentTrackId.
   */
  public audioResource = this.engine.resource(
    async (trackId, abortSignal) => {
      if (!trackId) return null

      if (!this.audioContext) {
        this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
      }

      // Если трек уже есть в кэше, возвращаем его мгновенно
      if (this.audioCache.has(trackId)) {
        return this.audioCache.get(trackId)!
      }

      const track = this.tracks.find(t => t.id === trackId)
      if (!track) throw new Error('Трек не найден')

      const response = await fetch(track.url, { signal: abortSignal })
      if (!response.ok) throw new Error(`Ошибка сети: ${response.status}`)

      const arrayBuffer = await response.arrayBuffer()
      const audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer)

      // Сохраняем в накопительный кэш
      this.audioCache.set(trackId, audioBuffer)
      return audioBuffer
    },
    this.currentTrackId,
    {
      name: 'player:resource:load-audio',
      validateBeforeFetch: (trackId) => !!trackId
    }
  )

  /**
   * Экшен простого выбора трека из списка (БЕЗ автоплея)
   */
  public selectTrack(trackId: string) {
    this.stop()
    this.currentTrackId.value = trackId
  }

  /**
   * Экшен запуска воспроизведения
   */
  public play() {
    const buffer = this.audioResource.data

    // Если файл еще грузится, произошла ошибка или музыка уже играет — выходим
    if (!buffer || !this.audioContext || this.isPlaying.value) return

    // Пробуждаем контекст (нужно, если браузер заблокировал его до первого клика)
    if (this.audioContext.state === 'suspended') {
      this.audioContext.resume()
    }

    // Создаем чистую одноразовую ноду источника звука
    this.audioSourceNode = this.audioContext.createBufferSource()
    this.audioSourceNode.buffer = buffer
    this.audioSourceNode.connect(this.audioContext.destination)

    // Коллбэк естественного окончания трека
    this.audioSourceNode.onended = () => {
      if (this.isPlaying.value) {
        this.isPlaying.value = false
      }
    }

    this.audioSourceNode.start(0)
    this.isPlaying.value = true
  }

  /**
   * Экшен полной остановки воспроизведения
   */
  public stop() {
    if (this.audioSourceNode) {
      try {
        this.audioSourceNode.stop()
      } catch (e) {
        // Игнорируем ошибки, если нода уже завершила работу
      }
      this.audioSourceNode.disconnect()
      this.audioSourceNode = null
    }

    this.isPlaying.value = false
  }

  /**
   * Деструктор сервиса для очистки при размонтировании
   */
  public destroy() {
    this.stop()
    if (this.audioContext) {
      this.audioContext.close()
      this.audioContext = null
    }
    this.currentTrackId.value = null
    this.audioCache.clear()
  }
}