Triển khai bộ điều khiển âm thanh trong ứng dụng Mini Program WeChat

Hình ảnh minh họa giao diện người dùng cuối cùng:

Yêu cầu chức năng cốt lõi

  • Phát/tạm dừng âm thanh, thay đổi trạng thái nút điều khiển trung tâm và cập nhật tiến trình phát theo thời gian thực;
  • Tua nhanh hoặc tua lùi 15 giây với kiểm tra giới hạn thời gian hợp lệ;
  • Kéo thả thanh trượt để định vị lại vị trí phát.

Ở giai đoạn thiết kế ban đầu, các yêu cầu này có vẻ đơn giản — chỉ gồm vài hành động cơ bản. Tuy nhiên, khi triển khai thực tế, nhiều điểm tinh vi về luồng điều khiển và quản lý trạng thái đã xuất hiện, đặc biệt liên quan đến InnerAudioContext và đồng bộ hóa giữa UI và trạng thái phát.

Các lưu ý kỹ thuật trọng yếu

  1. Quản lý vị trí phát hiện tại: Dùng biến playheadTime (thay vì seekPosition) để lưu thời điểm phát hiện tại — giá trị này được cập nhật liên tục qua sự kiện onTimeUpdate và làm cơ sở cho mọi thao tác tua và kéo thả.
  2. Kiểm tra biên giới thời gian: Khi tua nhanh, so sánh remainingDuration = totalDuration - playheadTime; nếu nhỏ hơn 15s thì đặt lại về 0. Khi tua lùi, đảm bảo playheadTime > 15, ngược lại đặt về 0.
  3. Hành vi kéo thả thông minh: Trước khi kéo, tạm dừng phát (audioCtx.pause()). Sau khi thả, không tự động phát lại — chỉ cập nhật vị trí con trỏ, chờ người dùng nhấn nút phát hoặc xử lý riêng ở lớp logic cao hơn.
  4. Xử lý trạng thái khởi tạo: Nếu người dùng nhấn phát lần đầu mà chưa có URL âm thanh, cần tải tài nguyên trước khi gọi play(). Đồng thời, nếu playheadTime > 0, gọi seek() ngay sau khi nguồn được gán.

Mã nguồn hoàn chỉnh

WXML — Giao diện người dùng

<view class="player-wrapper">
  <image src="{{coverUrl}}" class="cover-bg" mode="aspectFill" />
  <view class="overlay" />

  <view class="cover-art">
    <image src="{{coverUrl}}" class="cover-img" />
    <view class="track-title">{{trackName}}</view>
  </view>

  <view class="progress-row">
    <text class="time-label">{{formattedCurrent}}</text>
    <van-slider 
      value="{{sliderPercent}}" 
      step="0.5" 
      active-color="#2ecc71"
      bind:change="handleSeekEnd"
      bind:drag="handleSeeking"
      class="progress-slider"
    />
    <text class="time-label">{{formattedTotal}}</text>
  </view>

  <view class="control-bar">
    <van-icon name="replay" size="36rpx" color="#fff" bind:click="rewind15" />
    <van-icon 
      name="{{isPlaying ? 'pause' : 'play'}}" 
      size="64rpx" 
      color="#fff" 
      bind:click="{{isPlaying ? 'pauseAudio' : 'playAudio'}}" 
      class="play-btn"
    />
    <van-icon name="forward" size="36rpx" color="#fff" bind:click="fastForward15" />
  </view>
</view>

JS — Logic điều khiển

// components/audio-player/index.js
const audioCtx = wx.createInnerAudioContext();

Component({
  properties: {
    trackUrl: String,
    coverUrl: String,
    trackName: String,
    duration: Number // tính bằng giây, truyền từ cha hoặc lấy từ metadata
  },

  data: {
    isPlaying: false,
    isLoading: false,
    formattedCurrent: '00:00',
    formattedTotal: '00:00',
    sliderPercent: 0,
    playheadTime: 0,
    totalDuration: 0
  },

  lifetimes: {
    attached() {
      this._initAudioContext();
      this._setupEventListeners();
    },
    detached() {
      audioCtx.destroy();
    }
  },

  methods: {
    _initAudioContext() {
      const { trackUrl, duration } = this.data;
      if (trackUrl) {
        audioCtx.src = trackUrl;
        this.setData({ totalDuration: duration || 0 });
      }
    },

    _setupEventListeners() {
      audioCtx.onCanPlay(() => {
        this.setData({ isLoading: false, isPlaying: true });
        this._startProgressTracking();
      });

      audioCtx.onWaiting(() => {
        this.setData({ isLoading: true });
      });

      audioCtx.onEnded(() => {
        this.setData({
          isPlaying: false,
          playheadTime: 0,
          sliderPercent: 0,
          formattedCurrent: '00:00'
        });
      });

      audioCtx.onError((err) => {
        console.error('Audio error:', err);
      });
    },

    _startProgressTracking() {
      audioCtx.onTimeUpdate(() => {
        const time = audioCtx.currentTime;
        this.setData({
          playheadTime: time,
          formattedCurrent: this._formatTime(time),
          sliderPercent: time / (this.data.totalDuration || 1) * 100
        });
      });
    },

    _formatTime(seconds) {
      const mins = Math.floor(seconds / 60);
      const secs = Math.floor(seconds % 60);
      return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
    },

    playAudio() {
      const { trackUrl, playheadTime } = this.data;
      if (!trackUrl) return;

      if (playheadTime > 0) {
        audioCtx.seek(playheadTime);
      }
      audioCtx.play();
      this.setData({ isPlaying: true, isLoading: false });
    },

    pauseAudio() {
      audioCtx.pause();
      this.setData({ isPlaying: false });
    },

    rewind15() {
      const { playheadTime, totalDuration } = this.data;
      const newPosition = Math.max(0, playheadTime - 15);
      this._seekTo(newPosition);
    },

    fastForward15() {
      const { playheadTime, totalDuration } = this.data;
      const newPosition = Math.min(totalDuration, playheadTime + 15);
      this._seekTo(newPosition);
    },

    _seekTo(time) {
      const { isPlaying } = this.data;
      audioCtx.seek(time);
      this.setData({
        playheadTime: time,
        sliderPercent: (time / (this.data.totalDuration || 1)) * 100,
        formattedCurrent: this._formatTime(time)
      });
      if (isPlaying) audioCtx.play();
    },

    handleSeeking(e) {
      const percent = e.detail.value;
      const time = (percent / 100) * (this.data.totalDuration || 1);
      this.setData({
        playheadTime: time,
        formattedCurrent: this._formatTime(time)
      });
      this.pauseAudio(); // tạm dừng khi đang kéo
    },

    handleSeekEnd(e) {
      const percent = e.detail.value;
      const time = (percent / 100) * (this.data.totalDuration || 1);
      this._seekTo(time);
    }
  }
});

Lưu ý: Hàm _formatTime xử lý định dạng thời gian chuẩn MM:SS, hỗ trợ cả giá trị 0 và số thập phân. Toàn bộ logic tránh phụ thuộc vào hàm tiện ích bên ngoài như ss.formatSecToMin, đảm bảo tính độc lập và dễ kiểm thử.

Thẻ: weapp inner-audio-context mini-program wxss custom-component

Đăng vào ngày 8 tháng 6 lúc 02:29