You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Flutter just_audio在iOS下无法处理206部分内容响应问题

问题:iOS平台使用just_audio播放返回206状态码的音频异常

问题背景

开发的Flutter音乐应用基于just_audio包开发,Android平台运行正常,但iOS平台播放返回206部分内容状态码的音频时,出现无法播放、无法获取正确时长的问题。点击歌曲调用setNewPlaylist方法时抛出错误:Unhandled Exception: (-1) unknown error。

Song Handler核心代码

import 'dart:io';

import 'package:audio_service/audio_service.dart';
import 'package:audio_session/audio_session.dart';
import 'package:just_audio/just_audio.dart';
import 'package:http/http.dart' as http;
import '../app_export.dart';

class SongHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
  final AudioPlayer audioPlayer = AudioPlayer();

  final playlist =
      ConcatenatingAudioSource(children: [], useLazyPreparation: true);

  SongHandler() {
    initializeAudioSession();
  }

  // 音频会话配置
  Future<void> initializeAudioSession() async {
    final session = await AudioSession.instance;
    try {
      await session.configure(const AudioSessionConfiguration.music());
      session.interruptionEventStream.listen((event) async {
        if (event.begin) {
          switch (event.type) {
            case AudioInterruptionType.duck:
              await audioPlayer.setVolume(0.5);
              break;
            case AudioInterruptionType.pause:
            case AudioInterruptionType.unknown:
              await audioPlayer.pause();
              break;
          }
        } else {
          switch (event.type) {
            case AudioInterruptionType.duck:
              await audioPlayer.setVolume(1);
              break;
            case AudioInterruptionType.pause:
              await audioPlayer.play();
              break;
            case AudioInterruptionType.unknown:
              break;
          }
        }
      });
    } catch (e, stackTrace) {
      debugPrint('Error initializing audio session: $stackTrace');
    }
  }

  // 创建音频源
  UriAudioSource createAudioSource(MediaItem mediaItem) {
    return AudioSource.uri(Uri.parse(mediaItem.extras!['url'] as String),
        tag: mediaItem);
  }

  List<UriAudioSource> createAudioSources(List<MediaItem> mediaItems) {
    return mediaItems
        .map(
          (item) => AudioSource.uri(Uri.parse(item.extras!['url'] as String),
              tag: item,
              headers: {}),
        )
        .toList();
  }

  // 设置新播放列表
  Future<void> setNewPlaylist(List<MediaItem> mediaItems, int index) async {
    if (!Platform.isAndroid && audioPlayer.playing) {
      await audioPlayer.stop();
    }
    var getCount = queue.value.length;
    await playlist.removeRange(0, getCount);
    final audioSource = createAudioSources(mediaItems);
    await playlist.addAll(audioSource);
    final newQueue = queue.value..addAll(mediaItems);
    queue.add(newQueue);

    try {
      audioPlayer.setAudioSource(playlist,
          initialIndex: index, initialPosition: Duration.zero);
    } catch (e) {
      print("Error loading audio source: $e");
    }
  }
}

SongBloc时长更新核心逻辑

class SongBloc extends Bloc<SongEvent, SongState> {
  final NetworkInfo _networkInfo;
  final SongHandler songHandler;

  SongBloc(this._networkInfo, this.songHandler)
      : super(const SongState().copyWith(
            songsStatus: SongsStatus.initial,
            isPlaying: [],
            mediaItems: [],
            selectedSongId: "")) {
    _initializeListeners();

    on<SelectSong>(onSelectSong);
    // 其他事件处理逻辑省略
  }

  void _initializeListeners() {
    songHandler.audioPlayer.durationStream.listen((duration) {
      _handleDurationChange(duration);
    });
    // 其他监听器逻辑省略
  }

  Future<void> _handleDurationChange(Duration? duration) async {
    var index = songHandler.audioPlayer.currentIndex;
    final newQueue = state.mediaItems;
    if (index == null || newQueue!.isEmpty || newQueue.length < index) {
      return;
    }
    if (songHandler.audioPlayer.shuffleModeEnabled) {
      index = songHandler.audioPlayer.shuffleIndices!.indexOf(index);
    }
    final oldMediaItem = newQueue[index];
    final newMediaItem = oldMediaItem.copyWith(duration: duration);
    newQueue[index] = newMediaItem;

    emit(state.copyWith(songDuration: duration, mediaItem: newMediaItem));
  }

  // 选择歌曲事件处理
  Future<void> onSelectSong(SelectSong event, Emitter<SongState> emit) async {
    try {
      final mediaItems =
          event.songs.map(MediaConverter.mediaToMediaItem).toList();

      final MediaItem mediaItem = mediaItems[event.index];

      emit(state.copyWith(
        selectedSongId: mediaItem.id,
        selectedIndexSong: event.index,
      ));

      if (MediaConverter.areMediaItemsEqual(state.mediaItems!, mediaItems)) {
        await songHandler.skipToQueueItem(event.index);
      } else {
        await songHandler.setNewPlaylist(mediaItems, event.index);
        List<bool> isPlaying = List.filled(event.songs.length, false);
        emit(state.copyWith(isPlaying: isPlaying));
      }

      // 主题色生成逻辑省略

      songHandler.play();

      emit(state.copyWith(
        songsListEntity: event.songs,
        currentSong: event.songs[event.index],
        mediaItem: mediaItem,
        mediaItems: mediaItems,
        songsStatus: SongsStatus.selectSong,
      ));
    } catch (e) {
      emit(state.copyWith(
        songsStatus: SongsStatus.error,
        error: const PlaybackFailure('Failed to select song', ""),
      ));
    }
  }
}

服务器响应头信息

Accept-Ranges: bytes
Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept
Access-Control-Allow-Origin: *
Connection: keep-alive
Content-Length: 1000002
Content-Range: bytes 965921-1965921/11279288
Content-Type: audio/mpeg3
Date: Tue, 24 Sep 2024 08:05:29 GMT
Server: nginx/1.22.0
X-Powered-By: Express

尝试过的方案及遗留问题

尝试在请求头中添加Range字段,仅当Range设为空时iOS能播放,但此时时长显示为0,进度监听器异常,无法拖动进度条或正常流式播放:

List<UriAudioSource> createAudioSources(List<MediaItem> mediaItems) {
    return mediaItems
        .map(
          (item) => AudioSource.uri(Uri.parse(item.extras!['url'] as String),
              tag: item,
              headers: {
                "Range": "",
              }),
        )
        .toList();
  }

进度条组件代码

import 'package:audio_service/audio_service.dart';
import 'package:audio_video_progress_bar/audio_video_progress_bar.dart';
import '../../../../core/app_export.dart';
import '../bloc/song_bloc.dart';

class SongProgress extends StatelessWidget {
  final Duration? totalDuration;
  final Color? color;

  const SongProgress({
    super.key,
    this.totalDuration,
    this.color,
  });

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<Duration>(
      stream: AudioService.position,
      builder: (context, positionSnapshot) {
        Duration? position = positionSnapshot.data;
        return ProgressBar(
          progress: position ?? Duration.zero,
          buffered: const Duration(seconds: 1),
          total: totalDuration ?? Duration.zero,
          onSeek: (position) {
            BlocProvider.of<SongBloc>(context).songHandler.seek(position);
          },
          progressBarColor: color,
          baseBarColor: Colors.white.withOpacity(0.24),
          bufferedBarColor: Colors.white.withOpacity(0.24),
          thumbColor: Colors.white,
          barHeight: 3.h,
          thumbRadius: 5.h,
          timeLabelTextStyle: Styles.textStyle14.copyWith(fontWeight: FontWeight.bold),
        );
      },
    );
  }
}

解决方案建议

1. 修复Range请求头,强制获取完整音频元数据

iOS底层依赖的AVPlayer对206响应处理逻辑与Android不同,需明确请求完整音频内容来获取元数据。修改createAudioSources方法:

List<UriAudioSource> createAudioSources(List<MediaItem> mediaItems) {
  return mediaItems.map((item) {
    final headers = Platform.isIOS 
        ? {"Range": "bytes=0-"}  // 请求从开头到结尾的完整内容
        : {};
    return AudioSource.uri(
      Uri.parse(item.extras!['url'] as String),
      tag: item,
      headers: headers,
    );
  }).toList();
}

该请求头会让服务器返回完整音频(或包含完整元数据的响应),确保iOS能正确解析时长。

2. 手动预获取音频时长并填充MediaItem

如果just_audio仍无法从流中解析时长,可提前通过HEAD请求获取音频总长度:
在SongHandler中添加时长获取方法:

Future<Duration> fetchAudioDuration(String url) async {
  try {
    final response = await http.head(Uri.parse(url));
    if (response.statusCode == 200 || response.statusCode == 206) {
      // 从Content-Range提取总字节数
      final contentRange = response.headers['content-range'];
      if (contentRange != null) {
        final parts = contentRange.split('/');
        if (parts.length == 2) {
          final totalBytes = int.tryParse(parts[1]);
          if (totalBytes != null) {
            // 按128kbps比特率计算时长(可根据实际音频调整)
            const bitRate = 128000;
            return Duration(seconds: (totalBytes * 8) ~/ bitRate);
          }
        }
      }
      // 备用:从Content-Length获取
      final contentLength = int.tryParse(response.headers['content-length'] ?? '');
      if (contentLength != null) {
        const bitRate = 128000;
        return Duration(seconds: (contentLength * 8) ~/ bitRate);
      }
    }
  } catch (e) {
    debugPrint('Failed to fetch duration: $e');
  }
  return Duration.zero;
}

然后在创建MediaItem时预填充时长:

// 在MediaConverter.mediaToMediaItem方法中调用
final duration = await songHandler.fetchAudioDuration(song.url);
return MediaItem(
  id: song.id,
  title: song.title,
  extras: {'url': song.url},
  duration: duration,
);

3. 修复进度条的buffered值

当前进度条buffered值硬编码为1秒,需改为从audioPlayer的bufferedPositionStream获取:

class SongProgress extends StatelessWidget {
  final Duration? totalDuration;
  final Color? color;
  final SongHandler songHandler;

  const SongProgress({
    super.key,
    this.totalDuration,
    this.color,
    required this.songHandler,
  });

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<Duration>(
      stream: AudioService.position,
      builder: (context, positionSnapshot) {
        final position = positionSnapshot.data ?? Duration.zero;
        return StreamBuilder<Duration>(
          stream: songHandler.audioPlayer.bufferedPositionStream,
          builder: (context, bufferedSnapshot) {
            final buffered = bufferedSnapshot.data ?? Duration.zero;
            return ProgressBar(
              progress: position,
              buffered: buffered,
              total: totalDuration ?? Duration.zero,
              onSeek: (position) => songHandler.seek(position),
              progressBarColor: color,
              baseBarColor: Colors.white.withOpacity(0.24),
              bufferedBarColor: Colors.white.withOpacity(0.24),
              thumbColor: Colors.white,
              barHeight: 3.h,
              thumbRadius: 5.h,
              timeLabelTextStyle: Styles.textStyle14.copyWith(fontWeight: FontWeight.bold),
            );
          },
        );
      },
    );
  }
}

4. 强制服务器返回标准MIME类型

服务器返回的Content-Type为audio/mpeg3,iOS更兼容audio/mpeg或audio/mp3,可在请求头中添加Accept字段:

headers: {
  "Accept": "audio/mpeg",
  if (Platform.isIOS) "Range": "bytes=0-",
},

内容的提问来源于stack exchange,提问作者Bassam Jawish

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.18 00:02:04