Flutter实战:打造音乐播放器应用

前言

音乐播放器是一款经典的多媒体应用,让用户可以播放本地音乐文件并管理播放列表。本文将带你从零开始,使用Flutter开发一个功能完整的音乐播放器应用,支持音频播放、播放列表管理、播放控制等功能。

应用特色

  • 🎵 本地音乐:支持添加本地音频文件
  • ▶️ 播放控制:播放、暂停、上一首、下一首
  • 📊 进度条:实时显示播放进度,可拖动
  • 🔀 播放模式:顺序播放、随机播放、单曲循环
  • 📝 播放列表:创建和管理多个播放列表
  • 💾 数据持久化:音乐和播放列表本地保存
  • 🎨 Material Design 3:现代化UI设计
  • 📱 双标签页:所有音乐和播放列表分离
  • 🎯 快速操作:长按菜单快速操作
  • ⏱️ 时长显示:格式化显示播放时间

效果展示

在这里插入图片描述
在这里插入图片描述

音乐播放器

音乐管理

添加音乐

删除音乐

音乐列表

文件选择

播放控制

播放暂停

上一首

下一首

进度拖动

播放模式

顺序播放

随机播放

单曲循环

播放列表

创建列表

添加歌曲

加载列表

删除列表

依赖配置

首先在 pubspec.yaml 中添加依赖:

dependencies:
  flutter:
    sdk: flutter
  audioplayers: ^6.0.0        # 音频播放
  file_picker: ^8.0.0+1        # 文件选择
  shared_preferences: ^2.2.2   # 数据存储
  permission_handler: ^11.3.0  # 权限管理

运行命令安装依赖:

flutter pub get

数据模型设计

1. 音乐信息模型

class Music {
  String id;
  String title;
  String artist;
  String path;
  Duration duration;
  DateTime addedAt;

  Music({
    required this.id,
    required this.title,
    required this.artist,
    required this.path,
    required this.duration,
    required this.addedAt,
  });
}

字段说明

  • id:唯一标识符
  • title:歌曲标题
  • artist:艺术家名称
  • path:文件路径
  • duration:歌曲时长
  • addedAt:添加时间

2. 播放列表模型

class Playlist {
  String id;
  String name;
  List<String> musicIds;
  DateTime createdAt;

  Playlist({
    required this.id,
    required this.name,
    required this.musicIds,
    required this.createdAt,
  });
}

字段说明

  • id:播放列表ID
  • name:播放列表名称
  • musicIds:包含的音乐ID列表
  • createdAt:创建时间

核心功能实现

1. 音频播放器初始化

final AudioPlayer _audioPlayer = AudioPlayer();

void _initAudioPlayer() {
  // 监听播放状态
  _audioPlayer.onPlayerStateChanged.listen((state) {
    setState(() {
      _playerState = state;
      _isPlaying = state == PlayerState.playing;
    });
  });

  // 监听总时长
  _audioPlayer.onDurationChanged.listen((duration) {
    setState(() {
      _totalDuration = duration;
    });
  });

  // 监听播放进度
  _audioPlayer.onPositionChanged.listen((position) {
    setState(() {
      _currentPosition = position;
    });
  });

  // 监听播放完成
  _audioPlayer.onPlayerComplete.listen((event) {
    _onSongComplete();
  });
}

AudioPlayer事件

  • onPlayerStateChanged:播放状态变化
  • onDurationChanged:总时长变化
  • onPositionChanged:播放位置变化
  • onPlayerComplete:播放完成

2. 播放音乐

Future<void> _playMusic(Music music, int index) async {
  try {
    await _audioPlayer.stop();
    await _audioPlayer.play(DeviceFileSource(music.path));
    
    setState(() {
      _currentMusic = music;
      _currentIndex = index;
      _isPlaying = true;
    });
  } catch (e) {
    if (mounted) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('播放失败: $e')),
      );
    }
  }
}

播放流程

  1. 停止当前播放
  2. 使用DeviceFileSource加载本地文件
  3. 更新当前音乐和索引
  4. 捕获异常并提示用户

3. 播放控制

// 播放/暂停
Future<void> _togglePlayPause() async {
  if (_isPlaying) {
    await _audioPlayer.pause();
  } else {
    await _audioPlayer.resume();
  }
}

// 下一首
Future<void> _playNext() async {
  if (_currentPlaylist.isEmpty) return;
  
  int nextIndex;
  if (_isShuffle) {
    nextIndex = DateTime.now().millisecondsSinceEpoch % _currentPlaylist.length;
  } else {
    nextIndex = (_currentIndex + 1) % _currentPlaylist.length;
  }
  
  await _playMusic(_currentPlaylist[nextIndex], nextIndex);
}

// 上一首
Future<void> _playPrevious() async {
  if (_currentPlaylist.isEmpty) return;
  
  int prevIndex;
  if (_isShuffle) {
    prevIndex = DateTime.now().millisecondsSinceEpoch % _currentPlaylist.length;
  } else {
    prevIndex = (_currentIndex - 1 + _currentPlaylist.length) % _currentPlaylist.length;
  }
  
  await _playMusic(_currentPlaylist[prevIndex], prevIndex);
}

// 进度跳转
Future<void> _seekTo(Duration position) async {
  await _audioPlayer.seek(position);
}

播放模式

  • 顺序播放:按索引顺序播放
  • 随机播放:随机选择下一首
  • 单曲循环:播放完成后重新播放

4. 歌曲完成处理

void _onSongComplete() {
  if (_isRepeat) {
    _audioPlayer.seek(Duration.zero);
    _audioPlayer.resume();
  } else {
    _playNext();
  }
}

5. 添加音乐文件

Future<void> _pickMusicFiles() async {
  try {
    FilePickerResult? result = await FilePicker.platform.pickFiles(
      type: FileType.audio,
      allowMultiple: true,
    );

    if (result != null) {
      for (var file in result.files) {
        if (file.path != null) {
          final music = Music(
            id: DateTime.now().millisecondsSinceEpoch.toString(),
            title: file.name.replaceAll(RegExp(r'\.[^.]+$'), ''),
            artist: '未知艺术家',
            path: file.path!,
            duration: Duration.zero,
            addedAt: DateTime.now(),
          );
          
          setState(() {
            _allMusic.add(music);
            _currentPlaylist.add(music);
          });
        }
      }
      
      await _saveData();
    }
  } catch (e) {
    // 错误处理
  }
}

FilePicker参数

  • type: FileType.audio:只选择音频文件
  • allowMultiple: true:允许多选

6. 数据持久化

Future<void> _loadData() async {
  final prefs = await SharedPreferences.getInstance();
  
  // 加载音乐列表
  final musicJson = prefs.getString('music_list');
  if (musicJson != null) {
    final List<dynamic> decoded = json.decode(musicJson);
    setState(() {
      _allMusic = decoded.map((item) => Music.fromJson(item)).toList();
      _currentPlaylist = List.from(_allMusic);
    });
  }
  
  // 加载播放列表
  final playlistsJson = prefs.getString('playlists');
  if (playlistsJson != null) {
    final List<dynamic> decoded = json.decode(playlistsJson);
    setState(() {
      _playlists = decoded.map((item) => Playlist.fromJson(item)).toList();
    });
  }
}

Future<void> _saveData() async {
  final prefs = await SharedPreferences.getInstance();
  await prefs.setString(
    'music_list',
    json.encode(_allMusic.map((m) => m.toJson()).toList()),
  );
  await prefs.setString(
    'playlists',
    json.encode(_playlists.map((p) => p.toJson()).toList()),
  );
}

7. 播放列表管理

// 创建播放列表
void _createPlaylist() {
  final controller = TextEditingController();
  
  showDialog(
    context: context,
    builder: (context) => AlertDialog(
      title: const Text('创建播放列表'),
      content: TextField(
        controller: controller,
        decoration: const InputDecoration(
          labelText: '播放列表名称',
          hintText: '例如:我的最爱',
          border: OutlineInputBorder(),
        ),
        autofocus: true,
      ),
      actions: [
        TextButton(
          onPressed: () => Navigator.pop(context),
          child: const Text('取消'),
        ),
        FilledButton(
          onPressed: () {
            if (controller.text.isNotEmpty) {
              final playlist = Playlist(
                id: DateTime.now().millisecondsSinceEpoch.toString(),
                name: controller.text,
                musicIds: [],
                createdAt: DateTime.now(),
              );
              
              setState(() {
                _playlists.add(playlist);
              });
              _saveData();
              
              Navigator.pop(context);
            }
          },
          child: const Text('创建'),
        ),
      ],
    ),
  );
}

// 添加到播放列表
void _addToPlaylist(Music music) {
  showDialog(
    context: context,
    builder: (context) => AlertDialog(
      title: const Text('添加到播放列表'),
      content: ListView.builder(
        itemCount: _playlists.length,
        itemBuilder: (context, index) {
          final playlist = _playlists[index];
          final isAdded = playlist.musicIds.contains(music.id);
          
          return ListTile(
            title: Text(playlist.name),
            subtitle: Text('${playlist.musicIds.length} 首歌曲'),
            trailing: isAdded
                ? const Icon(Icons.check, color: Colors.green)
                : null,
            onTap: () {
              setState(() {
                if (isAdded) {
                  playlist.musicIds.remove(music.id);
                } else {
                  playlist.musicIds.add(music.id);
                }
              });
              _saveData();
              Navigator.pop(context);
            },
          );
        },
      ),
    ),
  );
}

// 加载播放列表
void _loadPlaylist(Playlist playlist) {
  final playlistMusic = _allMusic
      .where((music) => playlist.musicIds.contains(music.id))
      .toList();
  
  setState(() {
    _currentPlaylist = playlistMusic;
  });
}

UI组件设计

1. 音乐列表

Widget _buildMusicList() {
  if (_allMusic.isEmpty) {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Icon(
            Icons.music_off,
            size: 100,
            color: Colors.grey[400],
          ),
          const SizedBox(height: 24),
          Text(
            '还没有音乐',
            style: TextStyle(
              fontSize: 18,
              color: Colors.grey[600],
            ),
          ),
          const SizedBox(height: 24),
          FilledButton.icon(
            onPressed: _pickMusicFiles,
            icon: const Icon(Icons.add),
            label: const Text('添加音乐'),
          ),
        ],
      ),
    );
  }

  return ListView.builder(
    itemCount: _allMusic.length,
    itemBuilder: (context, index) {
      final music = _allMusic[index];
      final isPlaying = _currentMusic?.id == music.id && _isPlaying;
      
      return ListTile(
        leading: Container(
          width: 50,
          height: 50,
          decoration: BoxDecoration(
            color: isPlaying
                ? Theme.of(context).colorScheme.primaryContainer
                : Theme.of(context).colorScheme.surfaceContainerHighest,
            borderRadius: BorderRadius.circular(8),
          ),
          child: Icon(
            isPlaying ? Icons.equalizer : Icons.music_note,
            color: isPlaying
                ? Theme.of(context).colorScheme.primary
                : null,
          ),
        ),
        title: Text(
          music.title,
          maxLines: 1,
          overflow: TextOverflow.ellipsis,
        ),
        subtitle: Text(music.artist),
        trailing: PopupMenuButton<String>(
          icon: const Icon(Icons.more_vert),
          onSelected: (value) {
            switch (value) {
              case 'add_to_playlist':
                _addToPlaylist(music);
                break;
              case 'delete':
                _deleteMusic(music);
                break;
            }
          },
          itemBuilder: (context) => [
            const PopupMenuItem(
              value: 'add_to_playlist',
              child: Row(
                children: [
                  Icon(Icons.playlist_add),
                  SizedBox(width: 8),
                  Text('添加到播放列表'),
                ],
              ),
            ),
            const PopupMenuItem(
              value: 'delete',
              child: Row(
                children: [
                  Icon(Icons.delete, color: Colors.red),
                  SizedBox(width: 8),
                  Text('删除'),
                ],
              ),
            ),
          ],
        ),
        onTap: () => _playMusic(music, index),
      );
    },
  );
}

设计要点

  • 正在播放的歌曲高亮显示
  • 使用equalizer图标表示播放中
  • PopupMenuButton提供快捷操作

2. 播放控制器

Widget _buildPlayerControls() {
  return Container(
    padding: const EdgeInsets.all(16),
    decoration: BoxDecoration(
      color: Theme.of(context).colorScheme.surface,
      boxShadow: [
        BoxShadow(
          color: Colors.black.withValues(alpha: 0.1),
          blurRadius: 10,
          offset: const Offset(0, -2),
        ),
      ],
    ),
    child: Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        // 歌曲信息
        Row(
          children: [
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    _currentMusic!.title,
                    style: const TextStyle(
                      fontSize: 16,
                      fontWeight: FontWeight.bold,
                    ),
                    maxLines: 1,
                    overflow: TextOverflow.ellipsis,
                  ),
                  Text(
                    _currentMusic!.artist,
                    style: TextStyle(
                      fontSize: 14,
                      color: Colors.grey[600],
                    ),
                    maxLines: 1,
                    overflow: TextOverflow.ellipsis,
                  ),
                ],
              ),
            ),
          ],
        ),
        const SizedBox(height: 8),
        // 进度条
        Row(
          children: [
            Text(_formatDuration(_currentPosition)),
            Expanded(
              child: Slider(
                value: _currentPosition.inMilliseconds.toDouble(),
                max: _totalDuration.inMilliseconds.toDouble(),
                onChanged: (value) {
                  _seekTo(Duration(milliseconds: value.toInt()));
                },
              ),
            ),
            Text(_formatDuration(_totalDuration)),
          ],
        ),
        // 控制按钮
        Row(
          mainAxisAlignment: MainAxisAlignment.spaceEvenly,
          children: [
            IconButton(
              icon: Icon(
                _isShuffle ? Icons.shuffle_on_outlined : Icons.shuffle,
                color: _isShuffle ? Theme.of(context).colorScheme.primary : null,
              ),
              onPressed: () {
                setState(() {
                  _isShuffle = !_isShuffle;
                });
              },
              tooltip: '随机播放',
            ),
            IconButton(
              icon: const Icon(Icons.skip_previous),
              iconSize: 36,
              onPressed: _playPrevious,
              tooltip: '上一首',
            ),
            IconButton(
              icon: Icon(
                _isPlaying ? Icons.pause_circle_filled : Icons.play_circle_filled,
              ),
              iconSize: 56,
              onPressed: _togglePlayPause,
              tooltip: _isPlaying ? '暂停' : '播放',
            ),
            IconButton(
              icon: const Icon(Icons.skip_next),
              iconSize: 36,
              onPressed: _playNext,
              tooltip: '下一首',
            ),
            IconButton(
              icon: Icon(
                _isRepeat ? Icons.repeat_one : Icons.repeat,
                color: _isRepeat ? Theme.of(context).colorScheme.primary : null,
              ),
              onPressed: () {
                setState(() {
                  _isRepeat = !_isRepeat;
                });
              },
              tooltip: '单曲循环',
            ),
          ],
        ),
      ],
    ),
  );
}

控制按钮

  • 随机播放:shuffle图标
  • 上一首:skip_previous
  • 播放/暂停:play_circle_filled / pause_circle_filled
  • 下一首:skip_next
  • 单曲循环:repeat / repeat_one

3. 时长格式化

String _formatDuration(Duration duration) {
  String twoDigits(int n) => n.toString().padLeft(2, '0');
  final minutes = twoDigits(duration.inMinutes.remainder(60));
  final seconds = twoDigits(duration.inSeconds.remainder(60));
  return '$minutes:$seconds';
}

格式示例

  • 65秒 → “01:05”
  • 125秒 → “02:05”
  • 3665秒 → “61:05”

4. 标签页布局

DefaultTabController(
  length: 2,
  child: Column(
    children: [
      const TabBar(
        tabs: [
          Tab(text: '所有音乐', icon: Icon(Icons.music_note)),
          Tab(text: '播放列表', icon: Icon(Icons.playlist_play)),
        ],
      ),
      Expanded(
        child: TabBarView(
          children: [
            _buildMusicList(),
            _buildPlaylistsView(),
          ],
        ),
      ),
    ],
  ),
)

技术要点详解

1. AudioPlayer生命周期


void dispose() {
  _audioPlayer.dispose();
  super.dispose();
}

重要性

  • 释放音频资源
  • 停止后台播放
  • 避免内存泄漏

2. 播放状态管理

enum PlayerState {
  stopped,
  playing,
  paused,
  completed,
}

状态转换

stopped → playing → paused → playing → completed → stopped

3. 文件路径处理

// 本地文件
DeviceFileSource(music.path)

// 网络文件
UrlSource('https://example.com/music.mp3')

// 资源文件
AssetSource('assets/music.mp3')

4. 权限处理

AndroidManifest.xml 中添加:

<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>

Info.plist 中添加:

<key>NSAppleMusicUsageDescription</key>
<string>需要访问您的音乐库</string>

5. 后台播放

配置后台播放(Android):

<service android:name="xyz.luan.audioplayers.AudioplayersPlugin$AudioplayersService"
    android:exported="false">
    <intent-filter>
        <action android:name="android.media.browse.MediaBrowserService" />
    </intent-filter>
</service>

应用场景

1. 离线音乐播放器

class OfflineMusicPlayer {
  List<Music> downloadedMusic = [];
  
  Future<void> downloadMusic(String url, String title) async {
    // 下载音乐到本地
  }
  
  List<Music> getOfflineMusic() {
    return downloadedMusic;
  }
}

2. 播客播放器

class PodcastPlayer {
  double playbackSpeed = 1.0;
  
  Future<void> setSpeed(double speed) async {
    await _audioPlayer.setPlaybackRate(speed);
  }
  
  Future<void> skipForward(Duration duration) async {
    final newPosition = _currentPosition + duration;
    await _audioPlayer.seek(newPosition);
  }
}

3. 有声书播放器

class AudiobookPlayer {
  Map<String, Duration> bookmarks = {};
  
  void addBookmark(String name, Duration position) {
    bookmarks[name] = position;
  }
  
  Future<void> jumpToBookmark(String name) async {
    if (bookmarks.containsKey(name)) {
      await _audioPlayer.seek(bookmarks[name]!);
    }
  }
}

功能扩展建议

1. 歌词显示

class Lyrics {
  List<LyricLine> lines;
  
  Lyrics(this.lines);
  
  String getCurrentLine(Duration position) {
    for (var line in lines) {
      if (position >= line.startTime && position < line.endTime) {
        return line.text;
      }
    }
    return '';
  }
}

class LyricLine {
  Duration startTime;
  Duration endTime;
  String text;
  
  LyricLine(this.startTime, this.endTime, this.text);
}

2. 均衡器

class Equalizer {
  Map<String, double> bands = {
    '60Hz': 0.0,
    '230Hz': 0.0,
    '910Hz': 0.0,
    '3.6kHz': 0.0,
    '14kHz': 0.0,
  };
  
  void setBand(String frequency, double gain) {
    bands[frequency] = gain;
    // 应用均衡器设置
  }
  
  void applyPreset(String preset) {
    switch (preset) {
      case 'Rock':
        bands = {'60Hz': 5.0, '230Hz': 3.0, '910Hz': -2.0, '3.6kHz': 4.0, '14kHz': 5.0};
        break;
      case 'Pop':
        bands = {'60Hz': -1.0, '230Hz': 2.0, '910Hz': 4.0, '3.6kHz': 4.0, '14kHz': -1.0};
        break;
    }
  }
}

3. 睡眠定时器

class SleepTimer {
  Timer? _timer;
  
  void setTimer(Duration duration) {
    _timer?.cancel();
    _timer = Timer(duration, () {
      _audioPlayer.stop();
    });
  }
  
  void cancelTimer() {
    _timer?.cancel();
  }
}

4. 音乐可视化

import 'package:flutter_audio_visualizer/flutter_audio_visualizer.dart';

class MusicVisualizer extends StatelessWidget {
  final AudioPlayer player;
  
  const MusicVisualizer({required this.player});
  
  
  Widget build(BuildContext context) {
    return AudioVisualizer(
      audioPlayer: player,
      barCount: 50,
      barColor: Colors.blue,
      barWidth: 3,
      barSpace: 2,
    );
  }
}

5. 在线音乐搜索

class MusicSearch {
  Future<List<Music>> searchOnline(String query) async {
    final response = await http.get(
      Uri.parse('https://api.example.com/search?q=$query'),
    );
    
    if (response.statusCode == 200) {
      final data = json.decode(response.body);
      return (data['results'] as List)
          .map((item) => Music.fromJson(item))
          .toList();
    }
    
    return [];
  }
}

6. 音乐标签编辑

import 'package:audiotagger/audiotagger.dart';

class MusicTagger {
  final Audiotagger tagger = Audiotagger();
  
  Future<void> updateTags(String path, {
    String? title,
    String? artist,
    String? album,
  }) async {
    await tagger.writeTags(
      path: path,
      tagModel: TagModel(
        title: title,
        artist: artist,
        album: album,
      ),
    );
  }
  
  Future<TagModel?> readTags(String path) async {
    return await tagger.readTags(path: path);
  }
}

性能优化

1. 预加载下一首

void _preloadNext() {
  if (_currentIndex < _currentPlaylist.length - 1) {
    final nextMusic = _currentPlaylist[_currentIndex + 1];
    // 预加载但不播放
    _audioPlayer.setSourceDeviceFile(nextMusic.path);
  }
}

2. 缓存专辑封面

class AlbumArtCache {
  static final Map<String, Uint8List> _cache = {};
  
  static Future<Uint8List?> getAlbumArt(String musicPath) async {
    if (_cache.containsKey(musicPath)) {
      return _cache[musicPath];
    }
    
    // 从文件提取封面
    final art = await extractAlbumArt(musicPath);
    if (art != null) {
      _cache[musicPath] = art;
    }
    
    return art;
  }
}

3. 懒加载音乐列表

ListView.builder(
  itemCount: _allMusic.length,
  itemBuilder: (context, index) {
    if (index >= _allMusic.length) {
      return const CircularProgressIndicator();
    }
    return _buildMusicItem(_allMusic[index]);
  },
)

常见问题解答

Q1: 如何支持更多音频格式?

A: audioplayers支持大多数常见格式(MP3、WAV、AAC、OGG等),由底层平台决定。

Q2: 如何实现后台播放?

A: 需要配置平台特定的后台服务,并使用audio_service包。

Q3: 如何获取音频元数据?

A: 使用flutter_audio_metadata或audiotagger包读取ID3标签。

Q4: 播放网络音乐需要注意什么?

A: 需要处理网络延迟、缓冲、断网重连等情况。

项目结构

lib/
├── main.dart                    # 主程序入口
├── models/
│   ├── music.dart              # 音乐模型
│   └── playlist.dart           # 播放列表模型
├── screens/
│   ├── music_player_page.dart  # 播放器页面
│   ├── playlist_detail_page.dart # 播放列表详情
│   └── settings_page.dart      # 设置页面
├── widgets/
│   ├── music_list_item.dart    # 音乐列表项
│   ├── player_controls.dart    # 播放控制器
│   ├── progress_bar.dart       # 进度条
│   └── playlist_card.dart      # 播放列表卡片
└── utils/
    ├── audio_manager.dart      # 音频管理
    ├── storage_helper.dart     # 存储辅助
    └── format_helper.dart      # 格式化工具

总结

本文实现了一个功能完整的音乐播放器应用,涵盖了以下核心技术:

  1. AudioPlayer:音频播放和控制
  2. FilePicker:本地文件选择
  3. 播放控制:播放、暂停、上下首、进度控制
  4. 播放模式:顺序、随机、循环
  5. 播放列表:创建、管理、加载
  6. 数据持久化:SharedPreferences存储
  7. 状态管理:播放状态和进度监听

通过本项目,你不仅学会了如何实现音乐播放器应用,还掌握了Flutter中音频处理、文件操作、状态管理的核心技术。这些知识可以应用到更多多媒体应用的开发。

享受你的音乐时光!
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐