Flutter电台播放器应用开发教程

项目简介

电台播放器是一款在线电台收听应用,支持多种电台分类浏览、收藏管理和播放历史记录。应用提供简洁的界面和流畅的播放体验,让用户随时随地收听喜爱的电台节目。
运行效果图
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

核心特性

  • 多种分类:音乐、新闻、脱口秀、体育、文化、教育
  • 在线播放:支持在线电台流媒体播放
  • 收藏管理:收藏喜爱的电台
  • 播放历史:记录播放历史
  • 分类浏览:按分类筛选电台
  • 搜索功能:快速查找电台
  • 迷你播放器:底部常驻播放控制
  • 数据持久化:本地保存收藏和历史

技术栈

  • Flutter 3.x
  • Material Design 3
  • audioplayers(音频播放)
  • SharedPreferences(数据持久化)

数据模型设计

电台分类枚举

enum RadioCategory {
  music,      // 音乐
  news,       // 新闻
  talk,       // 脱口秀
  sports,     // 体育
  culture,    // 文化
  education,  // 教育
}

电台模型

class RadioStation {
  final String id;              // 唯一标识
  final String name;            // 电台名称
  final String description;     // 描述
  final String streamUrl;       // 流媒体URL
  final RadioCategory category; // 分类
  final String? imageUrl;       // 图标
  final String? website;        // 官网
  bool isFavorite;              // 是否收藏
}

播放历史模型

class PlayHistory {
  final String stationId;       // 电台ID
  final DateTime playTime;      // 播放时间
}

核心功能实现

1. 音频播放

使用audioplayers包实现电台播放:

final AudioPlayer _audioPlayer = AudioPlayer();

Future<void> _playStation(RadioStation station) async {
  setState(() {
    isLoading = true;
  });

  try {
    if (currentStation?.id == station.id && isPlaying) {
      await _audioPlayer.pause();
    } else {
      await _audioPlayer.stop();
      await _audioPlayer.play(UrlSource(station.streamUrl));
      
      setState(() {
        currentStation = station;
        isPlaying = true;
      });
      
      _addToHistory(station.id);
    }
  } catch (e) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text('播放失败: $e')),
    );
  } finally {
    setState(() {
      isLoading = false;
    });
  }
}

2. 播放状态监听

监听播放器状态变化:

void _setupAudioPlayer() {
  _audioPlayer.onPlayerStateChanged.listen((state) {
    setState(() {
      isPlaying = state == PlayerState.playing;
      isLoading = state == PlayerState.playing;
    });
  });
}

3. 收藏管理

void _toggleFavorite(String stationId) {
  setState(() {
    final station = stations.firstWhere((s) => s.id == stationId);
    station.isFavorite = !station.isFavorite;
  });
  _saveData();
}

Future<void> _saveData() async {
  final prefs = await SharedPreferences.getInstance();
  
  // 保存收藏
  final favoriteIds = stations
      .where((s) => s.isFavorite)
      .map((s) => s.id)
      .toList();
  await prefs.setStringList('favorite_stations', favoriteIds);
}

4. 播放历史

void _addToHistory(String stationId) {
  final history = PlayHistory(
    stationId: stationId,
    playTime: DateTime.now(),
  );
  
  setState(() {
    playHistory.insert(0, history);
    if (playHistory.length > 50) {
      playHistory.removeLast();
    }
  });
  
  _saveData();
}

5. 数据持久化

Future<void> _loadData() async {
  final prefs = await SharedPreferences.getInstance();
  
  // 加载收藏状态
  final favoritesData = prefs.getStringList('favorite_stations') ?? [];
  final favoriteIds = favoritesData.toSet();
  
  setState(() {
    for (var station in stations) {
      station.isFavorite = favoriteIds.contains(station.id);
    }
  });
  
  // 加载播放历史
  final historyData = prefs.getStringList('play_history') ?? [];
  setState(() {
    playHistory = historyData
        .map((json) => PlayHistory.fromJson(jsonDecode(json)))
        .toList();
  });
}

UI组件设计

1. 迷你播放器

底部常驻的播放控制器:

Widget _buildMiniPlayer() {
  return Container(
    decoration: BoxDecoration(
      color: Theme.of(context).colorScheme.surfaceVariant,
      boxShadow: [
        BoxShadow(
          color: Colors.black.withOpacity(0.1),
          blurRadius: 4,
          offset: const Offset(0, -2),
        ),
      ],
    ),
    child: ListTile(
      leading: Container(
        width: 48,
        height: 48,
        decoration: BoxDecoration(
          color: Theme.of(context).colorScheme.primary,
          borderRadius: BorderRadius.circular(8),
        ),
        child: Center(
          child: Text(currentStation!.imageUrl ?? '📻'),
        ),
      ),
      title: Text(currentStation!.name),
      subtitle: Text(currentStation!.description),
      trailing: Row(
        mainAxisSize: MainAxisSize.min,
        children: [
          IconButton(
            icon: Icon(isPlaying ? Icons.pause : Icons.play_arrow),
            onPressed: () => _playStation(currentStation!),
          ),
          IconButton(
            icon: const Icon(Icons.stop),
            onPressed: _stopPlaying,
          ),
        ],
      ),
    ),
  );
}

2. 电台卡片

Widget _buildStationCard(RadioStation station) {
  final isCurrentPlaying = currentStation?.id == station.id && isPlaying;

  return Card(
    child: ListTile(
      leading: Container(
        width: 56,
        height: 56,
        decoration: BoxDecoration(
          color: isCurrentPlaying
              ? Theme.of(context).colorScheme.primary
              : Theme.of(context).colorScheme.primaryContainer,
          borderRadius: BorderRadius.circular(8),
        ),
        child: Center(child: Text(station.imageUrl ?? '📻')),
      ),
      title: Text(
        station.name,
        style: TextStyle(
          fontWeight: isCurrentPlaying ? FontWeight.bold : FontWeight.normal,
        ),
      ),
      subtitle: Text(station.description),
      trailing: Row(
        mainAxisSize: MainAxisSize.min,
        children: [
          IconButton(
            icon: Icon(
              station.isFavorite ? Icons.favorite : Icons.favorite_outline,
              color: station.isFavorite ? Colors.red : null,
            ),
            onPressed: () => _toggleFavorite(station.id),
          ),
          IconButton(
            icon: Icon(isCurrentPlaying ? Icons.pause : Icons.play_arrow),
            onPressed: () => _playStation(station),
          ),
        ],
      ),
    ),
  );
}

3. 推荐卡片

横向滚动的推荐电台:

Widget _buildRecommendCard(RadioStation station) {
  return Card(
    child: InkWell(
      onTap: () => _playStation(station),
      child: Container(
        width: 150,
        padding: const EdgeInsets.all(12),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Container(
              width: 80,
              height: 80,
              decoration: BoxDecoration(
                color: Theme.of(context).colorScheme.primaryContainer,
                borderRadius: BorderRadius.circular(40),
              ),
              child: Center(child: Text(station.imageUrl ?? '📻')),
            ),
            const SizedBox(height: 12),
            Text(
              station.name,
              style: const TextStyle(fontWeight: FontWeight.bold),
              textAlign: TextAlign.center,
              maxLines: 2,
              overflow: TextOverflow.ellipsis,
            ),
          ],
        ),
      ),
    ),
  );
}

4. 分类筛选

Widget _buildCategoryFilter() {
  return SizedBox(
    height: 60,
    child: ListView.builder(
      scrollDirection: Axis.horizontal,
      itemCount: RadioCategory.values.length + 1,
      itemBuilder: (context, index) {
        if (index == 0) {
          return FilterChip(
            label: const Text('全部'),
            selected: selectedCategory == null,
            onSelected: (selected) {
              setState(() {
                selectedCategory = null;
              });
            },
          );
        }
        
        final category = RadioCategory.values[index - 1];
        return FilterChip(
          avatar: Icon(categoryIcons[category]),
          label: Text(categoryNames[category]!),
          selected: selectedCategory == category,
          onSelected: (selected) {
            setState(() {
              selectedCategory = selected ? category : null;
            });
          },
        );
      },
    ),
  );
}

应用架构

页面结构

RadioHomePage
主页面

首页
HomePage

分类页
CategoryPage

收藏页
FavoritesPage

历史页
HistoryPage

推荐电台

热门电台

分类筛选

电台列表

收藏列表

播放历史

迷你播放器

播放器对话框

数据流

用户选择电台

播放电台

AudioPlayer播放

更新播放状态

显示迷你播放器

添加到历史

保存到本地

用户收藏

更新收藏状态

保存到本地

应用启动

加载本地数据

恢复收藏状态

恢复播放历史

功能扩展建议

1. 定时关闭

添加睡眠定时器:

class SleepTimer {
  Timer? _timer;
  int remainingMinutes = 0;
  
  void start(int minutes, VoidCallback onComplete) {
    remainingMinutes = minutes;
    _timer = Timer.periodic(const Duration(minutes: 1), (timer) {
      remainingMinutes--;
      if (remainingMinutes <= 0) {
        timer.cancel();
        onComplete();
      }
    });
  }
  
  void cancel() {
    _timer?.cancel();
    remainingMinutes = 0;
  }
}

// 使用
Future<void> _showSleepTimerDialog() async {
  await showDialog(
    context: context,
    builder: (context) => AlertDialog(
      title: const Text('定时关闭'),
      content: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          ListTile(
            title: const Text('15分钟'),
            onTap: () {
              _sleepTimer.start(15, _stopPlaying);
              Navigator.pop(context);
            },
          ),
          ListTile(
            title: const Text('30分钟'),
            onTap: () {
              _sleepTimer.start(30, _stopPlaying);
              Navigator.pop(context);
            },
          ),
          ListTile(
            title: const Text('60分钟'),
            onTap: () {
              _sleepTimer.start(60, _stopPlaying);
              Navigator.pop(context);
            },
          ),
        ],
      ),
    ),
  );
}

2. 录音功能

录制电台节目:

// 使用 record 包
import 'package:record/record.dart';

class RadioRecorder {
  final Record _recorder = Record();
  bool isRecording = false;
  
  Future<void> startRecording(String stationName) async {
    if (await _recorder.hasPermission()) {
      final path = await _getRecordingPath(stationName);
      await _recorder.start(
        path: path,
        encoder: AudioEncoder.aacLc,
      );
      isRecording = true;
    }
  }
  
  Future<void> stopRecording() async {
    final path = await _recorder.stop();
    isRecording = false;
    return path;
  }
  
  Future<String> _getRecordingPath(String stationName) async {
    final directory = await getApplicationDocumentsDirectory();
    final timestamp = DateTime.now().millisecondsSinceEpoch;
    return '${directory.path}/${stationName}_$timestamp.aac';
  }
}

3. 均衡器

添加音频均衡器:

class AudioEqualizer extends StatefulWidget {
  final AudioPlayer player;
  
  
  State<AudioEqualizer> createState() => _AudioEqualizerState();
}

class _AudioEqualizerState extends State<AudioEqualizer> {
  Map<String, double> bands = {
    '60Hz': 0,
    '230Hz': 0,
    '910Hz': 0,
    '4kHz': 0,
    '14kHz': 0,
  };
  
  
  Widget build(BuildContext context) {
    return Column(
      children: bands.entries.map((entry) {
        return Column(
          children: [
            Text(entry.key),
            Slider(
              value: entry.value,
              min: -12,
              max: 12,
              divisions: 24,
              label: '${entry.value.toStringAsFixed(1)} dB',
              onChanged: (value) {
                setState(() {
                  bands[entry.key] = value;
                });
                _applyEqualizer();
              },
            ),
          ],
        );
      }).toList(),
    );
  }
  
  void _applyEqualizer() {
    // 应用均衡器设置到播放器
  }
}

4. 电台推荐

基于收听历史推荐电台:

class RadioRecommender {
  List<RadioStation> getRecommendations(
    List<RadioStation> allStations,
    List<PlayHistory> history,
  ) {
    // 统计各分类收听次数
    final categoryCount = <RadioCategory, int>{};
    
    for (var h in history) {
      final station = allStations.firstWhere((s) => s.id == h.stationId);
      categoryCount[station.category] = 
          (categoryCount[station.category] ?? 0) + 1;
    }
    
    // 找出最常听的分类
    final topCategory = categoryCount.entries
        .reduce((a, b) => a.value > b.value ? a : b)
        .key;
    
    // 推荐同分类的未收听电台
    return allStations
        .where((s) => 
          s.category == topCategory &&
          !history.any((h) => h.stationId == s.id)
        )
        .take(5)
        .toList();
  }
}

5. 电台节目表

显示电台节目时间表:

class ProgramSchedule {
  final String time;
  final String title;
  final String description;
  
  ProgramSchedule({
    required this.time,
    required this.title,
    required this.description,
  });
}

Widget _buildSchedulePage(RadioStation station) {
  final schedule = [
    ProgramSchedule(
      time: '06:00 - 08:00',
      title: '早间新闻',
      description: '最新新闻资讯',
    ),
    ProgramSchedule(
      time: '08:00 - 10:00',
      title: '音乐时光',
      description: '轻松音乐陪伴',
    ),
    // ... 更多节目
  ];
  
  return ListView.builder(
    itemCount: schedule.length,
    itemBuilder: (context, index) {
      final program = schedule[index];
      return ListTile(
        leading: const Icon(Icons.schedule),
        title: Text(program.title),
        subtitle: Text('${program.time}\n${program.description}'),
        isThreeLine: true,
      );
    },
  );
}

6. 社交分享

分享正在收听的电台:

// 使用 share_plus 包
import 'package:share_plus/share_plus.dart';

Future<void> _shareStation(RadioStation station) async {
  final text = '''
我正在收听:${station.name}
${station.description}
${station.website ?? ''}
''';
  
  await Share.share(text, subject: station.name);
}

7. 播客支持

添加播客节目支持:

class PodcastEpisode {
  final String id;
  final String title;
  final String description;
  final String audioUrl;
  final Duration duration;
  final DateTime publishDate;
  
  PodcastEpisode({
    required this.id,
    required this.title,
    required this.description,
    required this.audioUrl,
    required this.duration,
    required this.publishDate,
  });
}

class PodcastPlayer extends StatefulWidget {
  final PodcastEpisode episode;
  
  
  State<PodcastPlayer> createState() => _PodcastPlayerState();
}

class _PodcastPlayerState extends State<PodcastPlayer> {
  final AudioPlayer _player = AudioPlayer();
  Duration position = Duration.zero;
  Duration duration = Duration.zero;
  
  
  void initState() {
    super.initState();
    _player.onPositionChanged.listen((p) {
      setState(() {
        position = p;
      });
    });
    
    _player.onDurationChanged.listen((d) {
      setState(() {
        duration = d;
      });
    });
  }
  
  
  Widget build(BuildContext context) {
    return Column(
      children: [
        Slider(
          value: position.inSeconds.toDouble(),
          max: duration.inSeconds.toDouble(),
          onChanged: (value) {
            _player.seek(Duration(seconds: value.toInt()));
          },
        ),
        Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            IconButton(
              icon: const Icon(Icons.replay_10),
              onPressed: () {
                final newPosition = position - const Duration(seconds: 10);
                _player.seek(newPosition);
              },
            ),
            IconButton(
              icon: const Icon(Icons.play_arrow),
              onPressed: () => _player.play(UrlSource(widget.episode.audioUrl)),
            ),
            IconButton(
              icon: const Icon(Icons.forward_10),
              onPressed: () {
                final newPosition = position + const Duration(seconds: 10);
                _player.seek(newPosition);
              },
            ),
          ],
        ),
      ],
    );
  }
}

8. 离线下载

下载电台节目离线收听:

// 使用 dio 包
import 'package:dio/dio.dart';

class RadioDownloader {
  final Dio _dio = Dio();
  
  Future<void> downloadEpisode(
    PodcastEpisode episode,
    Function(double) onProgress,
  ) async {
    final directory = await getApplicationDocumentsDirectory();
    final savePath = '${directory.path}/${episode.id}.mp3';
    
    await _dio.download(
      episode.audioUrl,
      savePath,
      onReceiveProgress: (received, total) {
        if (total != -1) {
          onProgress(received / total);
        }
      },
    );
  }
  
  Future<List<String>> getDownloadedEpisodes() async {
    final directory = await getApplicationDocumentsDirectory();
    final files = directory.listSync();
    return files
        .where((f) => f.path.endsWith('.mp3'))
        .map((f) => f.path)
        .toList();
  }
}

9. 车载模式

简化界面适配车载使用:

class CarMode extends StatelessWidget {
  final RadioStation? currentStation;
  final bool isPlaying;
  final VoidCallback onPlayPause;
  final VoidCallback onNext;
  final VoidCallback onPrevious;
  
  
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.black,
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(
              currentStation?.name ?? '未播放',
              style: const TextStyle(
                color: Colors.white,
                fontSize: 48,
                fontWeight: FontWeight.bold,
              ),
            ),
            const SizedBox(height: 60),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                IconButton(
                  icon: const Icon(Icons.skip_previous, color: Colors.white),
                  iconSize: 80,
                  onPressed: onPrevious,
                ),
                const SizedBox(width: 40),
                IconButton(
                  icon: Icon(
                    isPlaying ? Icons.pause_circle : Icons.play_circle,
                    color: Colors.white,
                  ),
                  iconSize: 120,
                  onPressed: onPlayPause,
                ),
                const SizedBox(width: 40),
                IconButton(
                  icon: const Icon(Icons.skip_next, color: Colors.white),
                  iconSize: 80,
                  onPressed: onNext,
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

10. 语音控制

添加语音命令控制:

// 使用 speech_to_text 包
import 'package:speech_to_text/speech_to_text.dart';

class VoiceController {
  final SpeechToText _speech = SpeechToText();
  
  Future<void> startListening(Function(String) onCommand) async {
    bool available = await _speech.initialize();
    
    if (available) {
      _speech.listen(
        onResult: (result) {
          final command = result.recognizedWords.toLowerCase();
          
          if (command.contains('播放')) {
            onCommand('play');
          } else if (command.contains('暂停')) {
            onCommand('pause');
          } else if (command.contains('停止')) {
            onCommand('stop');
          } else if (command.contains('下一个')) {
            onCommand('next');
          } else if (command.contains('上一个')) {
            onCommand('previous');
          }
        },
      );
    }
  }
  
  void stopListening() {
    _speech.stop();
  }
}

性能优化建议

1. 音频缓冲

优化音频流缓冲:

await _audioPlayer.setReleaseMode(ReleaseMode.stop);
await _audioPlayer.setPlayerMode(PlayerMode.mediaPlayer);

// 设置缓冲大小
await _audioPlayer.setSourceUrl(
  station.streamUrl,
  isLocal: false,
);

2. 图片缓存

使用cached_network_image缓存电台图标:

// 使用 cached_network_image 包
import 'package:cached_network_image/cached_network_image.dart';

Widget _buildStationImage(String? imageUrl) {
  if (imageUrl == null || imageUrl.startsWith('http')) {
    return CachedNetworkImage(
      imageUrl: imageUrl ?? '',
      placeholder: (context, url) => const CircularProgressIndicator(),
      errorWidget: (context, url, error) => const Icon(Icons.radio),
    );
  }
  return Text(imageUrl);  // Emoji
}

3. 列表优化

使用ListView.builder优化长列表:

ListView.builder(
  itemCount: stations.length,
  itemBuilder: (context, index) {
    return _buildStationCard(stations[index]);
  },
  // 添加缓存范围
  cacheExtent: 500,
)

测试建议

1. 单元测试

测试播放逻辑:

import 'package:flutter_test/flutter_test.dart';

void main() {
  group('Radio Player Tests', () {
    test('Toggle favorite', () {
      final station = RadioStation(
        id: '1',
        name: 'Test',
        description: 'Test',
        streamUrl: 'test',
        category: RadioCategory.music,
        isFavorite: false,
      );
      
      station.isFavorite = !station.isFavorite;
      expect(station.isFavorite, true);
    });
  });
}

2. Widget测试

测试UI组件:

testWidgets('Station card displays correctly', (WidgetTester tester) async {
  final station = RadioStation(
    id: '1',
    name: 'Test Station',
    description: 'Test Description',
    streamUrl: 'test',
    category: RadioCategory.music,
  );
  
  await tester.pumpWidget(
    MaterialApp(
      home: Scaffold(
        body: StationCard(station: station),
      ),
    ),
  );
  
  expect(find.text('Test Station'), findsOneWidget);
  expect(find.text('Test Description'), findsOneWidget);
});

部署发布

1. 依赖配置

pubspec.yaml中添加:

dependencies:
  audioplayers: ^6.0.0
  shared_preferences: ^2.2.2

2. 权限配置

Android (android/app/src/main/AndroidManifest.xml):

<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>

iOS (ios/Runner/Info.plist):

<key>NSAppTransportSecurity</key>
<dict>
    <key>NSAllowsArbitraryLoads</key>
    <true/>
</dict>

3. 打包

# Android
flutter build apk --release

# iOS
flutter build ios --release

项目总结

通过开发这个电台播放器应用,你将掌握:

  • Flutter音频播放技术
  • 流媒体处理
  • 状态管理和数据持久化
  • 复杂UI布局设计
  • 用户交互优化

这个应用展示了Flutter在多媒体应用开发中的强大能力,可以继续扩展更多功能,打造专业的电台播放平台!
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

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

更多推荐