這次的作業是寫一個音樂播放器,雖然說可能沒有做得太精緻,但是還是盡量把功能做好。儘管沒有提供網路上搜尋音樂然後就可以直接播放的功能,但基本上還是有因為要減少應用程式的體積,還有在通知欄控制音樂播放,所以有將音樂先下載,然後臨時在網路上架一個小伺服器,讓音樂可以播放的同時,讓應用程式體積可以更小。
CAUTION雖然說下方文章內容會有該小伺服器連結,但該網站上的資源與歌曲皆為流行歌,故在此僅供寫作業用,請勿使用該網站資源進行營利等其他行為。
Preview
這次圖只有一張,如下圖所示: 沒錯,乾淨又簡單
Code Explanation
因為這次時間比較緊,所以採用 AI 去做了介面。又因為 VSCodium 的 GitHub Copilot 程式碼編輯功能不知道跑去哪了,而 Zed 編輯器的 AI 程式碼編輯一次又只能請它改你選取的區域,所以這次檔案只有一個主檔案跟一個宣告資料類型的檔案。widget 我也懶得分檔案了,反正 widget 都一次性。
Data Model
用以記錄歌曲的相關資料
class Song { final String title; final String artist; final String imageName; final String audioName;
Song({ required this.title, required this.artist, required this.imageName, required this.audioName, });}
Entrance
這裡主要就是做一些初始設定,比如在 App 中調音量時不會顯示系統的音量調整,並且初始化背景播放、通知控制等相關的東西。
4 collapsed lines
import 'dart:async';import 'dart:collection';import 'dart:math';
import 'package:flutter/material.dart';1 collapsed line
import 'package:just_audio/just_audio.dart';import 'package:just_audio_background/just_audio_background.dart';2 collapsed lines
import 'package:musicplayer/song.dart';import 'package:rxdart/rxdart.dart';import 'package:volume_controller/volume_controller.dart';
Future<void> main() async { VolumeController.instance.showSystemUI = false; await JustAudioBackground.init(); runApp(const MyApp());}404 collapsed lines
class MyApp extends StatelessWidget { const MyApp({super.key});
@override Widget build(BuildContext context) { return MaterialApp( title: 'Music App', theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), scaffoldBackgroundColor: Colors.black, textTheme: const TextTheme( bodyMedium: TextStyle(color: Colors.white), headlineMedium: TextStyle(color: Colors.white), ), ), debugShowCheckedModeBanner: false, home: const MyHomePage(title: 'Music App'), ); }}
class MyHomePage extends StatefulWidget { const MyHomePage({super.key, required this.title});
final String title;
@override State<MyHomePage> createState() => _MyHomePageState();}
class _MyHomePageState extends State<MyHomePage> { final AudioPlayer _audioPlayer = AudioPlayer(); late final VolumeController _volumeController; late final StreamSubscription<double> _subscription; final HashMap<LoopMode, int> loopModeToIndex = HashMap.of({ LoopMode.off: 0, LoopMode.all: 1, LoopMode.one: 2, }); final HashMap<int, LoopMode> indexToLoopMode = HashMap.of({ 0: LoopMode.off, 1: LoopMode.all, 2: LoopMode.one, }); List<Color> colors = [Colors.grey.shade700, Colors.white, Colors.white]; List<Icon> icons = [ const Icon(Icons.repeat), const Icon(Icons.repeat), const Icon(Icons.repeat_one), ]; List<Song> songs = [ Song( title: 'Numb', artist: 'Will Pan', imageName: 'IMG_6398.jpg', audioName: 'Numb.mp3', ), Song( title: 'Colorful', artist: 'Hiiragi Magnetite', imageName: 'IMG_6399.jpg', audioName: 'カラフル.mp3', ), Song( title: 'もしも', artist: 'ナナツカゼ', imageName: 'IMG_6400.jpg', audioName: 'What If.mp3', ), Song( title: 'サンダーソニア', artist: '小花依ある', imageName: 'IMG_6412.jpg', audioName: 'サンダーソニア.mp3', ), Song( title: 'I Am I Can', artist: 'Nine Chen', imageName: 'IMG_6413.jpg', audioName: 'I am I can.mp3', ), ]; LoopMode prevLoopMode = LoopMode.off; double _volumeValue = 0; Duration _currentPosition = Duration.zero; bool changingPosition = false;
_MyHomePageState() { _audioPlayer.setVolume(1.0); _audioPlayer.setLoopMode(LoopMode.off); _audioPlayer.setShuffleModeEnabled(false); _audioPlayer.setAudioSources( songs.map((song) { return AudioSource.uri( Uri.parse( 'https://homeworkfileserver.onrender.com/music/${song.audioName}', ), tag: MediaItem( id: song.audioName, title: song.title, artist: song.artist, artUri: Uri.parse( 'https://homeworkfileserver.onrender.com/image/${song.imageName}', ), ), ); }).toList(), initialIndex: 0, initialPosition: Duration.zero, preload: true, ); _audioPlayer.sequenceStateStream.listen((state) { if (state.currentIndex != null) { setState(() {}); } }); _audioPlayer.playerStateStream.listen((state) async { if (state.processingState == ProcessingState.completed) { if (!_audioPlayer.hasNext && _audioPlayer.loopMode == LoopMode.off && _audioPlayer.shuffleModeEnabled == false) { await _audioPlayer.seek(Duration.zero, index: 0); await _audioPlayer.stop(); } } setState(() {}); }); _volumeController = VolumeController.instance;
_subscription = _volumeController.addListener((volume) { setState(() => _volumeValue = volume); }, fetchInitialVolume: true); }
Stream<DurationState> get _durationStateStream => Rx.combineLatest2<Duration, Duration?, DurationState>( _audioPlayer.positionStream, _audioPlayer.durationStream, (position, duration) => DurationState(position: position, total: duration ?? Duration.zero), );
@override void dispose() { _audioPlayer.dispose(); super.dispose(); }
void _playPauseAudio() async { if (_audioPlayer.playing) { await _audioPlayer.pause(); } else { await _audioPlayer.play(); } }
void _setVolume(double volume) async { await _volumeController.setVolume(volume); setState(() {}); }
@override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( backgroundColor: Colors.black, title: Text(widget.title, style: const TextStyle(color: Colors.white)), ), body: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Padding( padding: EdgeInsets.all(16.0), child: Text( 'Now Playing', style: TextStyle( fontSize: 28, fontWeight: FontWeight.bold, color: Colors.white, ), ), ), Padding( padding: EdgeInsets.symmetric(horizontal: 16.0), child: Text( '${songs[_audioPlayer.currentIndex ?? 0].artist} - ${songs[_audioPlayer.currentIndex ?? 0].title}', style: TextStyle(fontSize: 20, color: Colors.white70), textAlign: TextAlign.center, ), ), const SizedBox(height: 20), SizedBox( width: 300, height: 300, child: ClipRRect( borderRadius: BorderRadius.circular(16.0), child: Image.network( "https://homeworkfileserver.onrender.com/image/${songs[_audioPlayer.currentIndex ?? 0].imageName}", fit: BoxFit.cover, ), ), ), const SizedBox(height: 30), StreamBuilder<DurationState>( stream: _durationStateStream, builder: (context, snapshot) { final durationState = snapshot.data; final position = durationState?.position ?? Duration.zero; final total = durationState?.total ?? Duration.zero;
return Column( children: [ SizedBox( width: 400, child: Slider( value: changingPosition ? _currentPosition.inSeconds.toDouble() : min( total.inSeconds.toDouble(), position.inSeconds.toDouble(), ), max: total.inSeconds.toDouble(), onChangeStart: (value) { changingPosition = true; _currentPosition = position; }, onChanged: (value) { _currentPosition = Duration(seconds: value.toInt()); }, onChangeEnd: (value) async { await _audioPlayer.seek( Duration(seconds: value.toInt()), ); changingPosition = false; }, activeColor: Colors.white, inactiveColor: Colors.white54, ), ), SizedBox( width: 400, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( _formatDuration( changingPosition ? _currentPosition : position, ), style: const TextStyle(color: Colors.white), ), Text( _formatDuration(total), style: const TextStyle(color: Colors.white), ), ], ), ), ), ], ); }, ), const SizedBox(height: 30), Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ IconButton( icon: const Icon(Icons.skip_previous, color: Colors.white), iconSize: 48, onPressed: () async { if (_audioPlayer.hasPrevious) { await _audioPlayer.seekToPrevious(); } else { await _audioPlayer.seek( Duration.zero, index: songs.length - 1, ); } }, ), IconButton( icon: Icon( _audioPlayer.playing ? Icons.pause_circle_filled : Icons.play_circle_filled, color: Colors.white, ), iconSize: 64, onPressed: _playPauseAudio, ), IconButton( icon: const Icon(Icons.skip_next, color: Colors.white), iconSize: 48, onPressed: () async { if (_audioPlayer.hasNext) { await _audioPlayer.seekToNext(); } else { await _audioPlayer.seek(Duration.zero, index: 0); } }, ), ], ), const SizedBox(height: 20), SizedBox( width: 400, child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ const Icon(Icons.volume_up, color: Colors.white), SizedBox( width: 350, child: Slider( value: _volumeValue, min: 0.0, max: 1.0, onChanged: _setVolume, activeColor: Colors.white, inactiveColor: Colors.white54, ), ), ], ), ), const SizedBox(height: 20), Padding( padding: EdgeInsets.only(bottom: 20), child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ IconButton( onPressed: () async { await _audioPlayer.seek( null, index: Random().nextInt(songs.length), ); }, icon: Icon(Icons.casino), color: Colors.white, iconSize: 30, ), IconButton( onPressed: () async { await _audioPlayer.setShuffleModeEnabled( !_audioPlayer.shuffleModeEnabled, ); if (_audioPlayer.shuffleModeEnabled) { prevLoopMode = _audioPlayer.loopMode; await _audioPlayer.setLoopMode(LoopMode.all); } else { await _audioPlayer.setLoopMode(prevLoopMode); } setState(() {}); }, icon: Icon( _audioPlayer.shuffleModeEnabled ? Icons.shuffle : Icons.double_arrow, ), color: Colors.white, iconSize: 30, ), IconButton( onPressed: _audioPlayer.shuffleModeEnabled ? null : (() async { int loopMode = loopModeToIndex[_audioPlayer.loopMode]!; loopMode = (loopMode + 1) % 3; await _audioPlayer.setLoopMode( indexToLoopMode[loopMode]!, ); setState(() {}); }), icon: icons[loopModeToIndex[_audioPlayer.loopMode]!], color: colors[loopModeToIndex[_audioPlayer.loopMode]!], iconSize: 30, ), ], ), ), ], ), ); }
String _formatDuration(Duration duration) { final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0'); final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0'); return '$minutes:$seconds'; }}
class DurationState { final Duration position; final Duration total;
DurationState({required this.position, required this.total});}
Base Widget and States
整個基底 widget、常值、狀態和一些運作中會需要的東西。例如用來播放音樂的 _audioPlayer
,還有跟系統音量控制有關的 _volumeController
和 _subscription
。
import 'dart:async';import 'dart:collection';import 'dart:math';2 collapsed lines
import 'package:flutter/material.dart';import 'package:just_audio/just_audio.dart';1 collapsed line
import 'package:just_audio_background/just_audio_background.dart';import 'package:musicplayer/song.dart';1 collapsed line
import 'package:rxdart/rxdart.dart';import 'package:volume_controller/volume_controller.dart';27 collapsed lines
Future<void> main() async { VolumeController.instance.showSystemUI = false; await JustAudioBackground.init(); runApp(const MyApp());}
class MyApp extends StatelessWidget { const MyApp({super.key});
@override Widget build(BuildContext context) { return MaterialApp( title: 'Music App', theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), scaffoldBackgroundColor: Colors.black, textTheme: const TextTheme( bodyMedium: TextStyle(color: Colors.white), headlineMedium: TextStyle(color: Colors.white), ), ), debugShowCheckedModeBanner: false, home: const MyHomePage(title: 'Music App'), ); }}
class MyHomePage extends StatefulWidget { const MyHomePage({super.key, required this.title});
final String title;
@override State<MyHomePage> createState() => _MyHomePageState();}
class _MyHomePageState extends State<MyHomePage> { final AudioPlayer _audioPlayer = AudioPlayer(); late final VolumeController _volumeController; late final StreamSubscription<double> _subscription; final HashMap<LoopMode, int> loopModeToIndex = HashMap.of({ LoopMode.off: 0, LoopMode.all: 1, LoopMode.one: 2, }); final HashMap<int, LoopMode> indexToLoopMode = HashMap.of({ 0: LoopMode.off, 1: LoopMode.all, 2: LoopMode.one, }); List<Color> colors = [Colors.grey.shade700, Colors.white, Colors.white]; List<Icon> icons = [ const Icon(Icons.repeat), const Icon(Icons.repeat), const Icon(Icons.repeat_one), ]; List<Song> songs = [ Song( title: 'Numb', artist: 'Will Pan', imageName: 'IMG_6398.jpg', audioName: 'Numb.mp3', ), Song( title: 'Colorful', artist: 'Hiiragi Magnetite', imageName: 'IMG_6399.jpg', audioName: 'カラフル.mp3', ), Song( title: 'もしも', artist: 'ナナツカゼ', imageName: 'IMG_6400.jpg', audioName: 'What If.mp3', ), Song( title: 'サンダーソニア', artist: '小花依ある', imageName: 'IMG_6412.jpg', audioName: 'サンダーソニア.mp3', ), Song( title: 'I Am I Can', artist: 'Nine Chen', imageName: 'IMG_6413.jpg', audioName: 'I am I can.mp3', ), ]; LoopMode prevLoopMode = LoopMode.off; double _volumeValue = 0; Duration _currentPosition = Duration.zero; bool changingPosition = false;309 collapsed lines
_MyHomePageState() { _audioPlayer.setVolume(1.0); _audioPlayer.setLoopMode(LoopMode.off); _audioPlayer.setShuffleModeEnabled(false); _audioPlayer.setAudioSources( songs.map((song) { return AudioSource.uri( Uri.parse( 'https://homeworkfileserver.onrender.com/music/${song.audioName}', ), tag: MediaItem( id: song.audioName, title: song.title, artist: song.artist, artUri: Uri.parse( 'https://homeworkfileserver.onrender.com/image/${song.imageName}', ), ), ); }).toList(), initialIndex: 0, initialPosition: Duration.zero, preload: true, ); _audioPlayer.sequenceStateStream.listen((state) { if (state.currentIndex != null) { setState(() {}); } }); _audioPlayer.playerStateStream.listen((state) async { if (state.processingState == ProcessingState.completed) { if (!_audioPlayer.hasNext && _audioPlayer.loopMode == LoopMode.off && _audioPlayer.shuffleModeEnabled == false) { await _audioPlayer.seek(Duration.zero, index: 0); await _audioPlayer.stop(); } } setState(() {}); }); _volumeController = VolumeController.instance;
_subscription = _volumeController.addListener((volume) { setState(() => _volumeValue = volume); }, fetchInitialVolume: true); }
Stream<DurationState> get _durationStateStream => Rx.combineLatest2<Duration, Duration?, DurationState>( _audioPlayer.positionStream, _audioPlayer.durationStream, (position, duration) => DurationState(position: position, total: duration ?? Duration.zero), );
@override void dispose() { _audioPlayer.dispose(); super.dispose(); }
void _playPauseAudio() async { if (_audioPlayer.playing) { await _audioPlayer.pause(); } else { await _audioPlayer.play(); } }
void _setVolume(double volume) async { await _volumeController.setVolume(volume); setState(() {}); }
@override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( backgroundColor: Colors.black, title: Text(widget.title, style: const TextStyle(color: Colors.white)), ), body: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Padding( padding: EdgeInsets.all(16.0), child: Text( 'Now Playing', style: TextStyle( fontSize: 28, fontWeight: FontWeight.bold, color: Colors.white, ), ), ), Padding( padding: EdgeInsets.symmetric(horizontal: 16.0), child: Text( '${songs[_audioPlayer.currentIndex ?? 0].artist} - ${songs[_audioPlayer.currentIndex ?? 0].title}', style: TextStyle(fontSize: 20, color: Colors.white70), textAlign: TextAlign.center, ), ), const SizedBox(height: 20), SizedBox( width: 300, height: 300, child: ClipRRect( borderRadius: BorderRadius.circular(16.0), child: Image.network( "https://homeworkfileserver.onrender.com/image/${songs[_audioPlayer.currentIndex ?? 0].imageName}", fit: BoxFit.cover, ), ), ), const SizedBox(height: 30), StreamBuilder<DurationState>( stream: _durationStateStream, builder: (context, snapshot) { final durationState = snapshot.data; final position = durationState?.position ?? Duration.zero; final total = durationState?.total ?? Duration.zero;
return Column( children: [ SizedBox( width: 400, child: Slider( value: changingPosition ? _currentPosition.inSeconds.toDouble() : min( total.inSeconds.toDouble(), position.inSeconds.toDouble(), ), max: total.inSeconds.toDouble(), onChangeStart: (value) { changingPosition = true; _currentPosition = position; }, onChanged: (value) { _currentPosition = Duration(seconds: value.toInt()); }, onChangeEnd: (value) async { await _audioPlayer.seek( Duration(seconds: value.toInt()), ); changingPosition = false; }, activeColor: Colors.white, inactiveColor: Colors.white54, ), ), SizedBox( width: 400, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( _formatDuration( changingPosition ? _currentPosition : position, ), style: const TextStyle(color: Colors.white), ), Text( _formatDuration(total), style: const TextStyle(color: Colors.white), ), ], ), ), ), ], ); }, ), const SizedBox(height: 30), Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ IconButton( icon: const Icon(Icons.skip_previous, color: Colors.white), iconSize: 48, onPressed: () async { if (_audioPlayer.hasPrevious) { await _audioPlayer.seekToPrevious(); } else { await _audioPlayer.seek( Duration.zero, index: songs.length - 1, ); } }, ), IconButton( icon: Icon( _audioPlayer.playing ? Icons.pause_circle_filled : Icons.play_circle_filled, color: Colors.white, ), iconSize: 64, onPressed: _playPauseAudio, ), IconButton( icon: const Icon(Icons.skip_next, color: Colors.white), iconSize: 48, onPressed: () async { if (_audioPlayer.hasNext) { await _audioPlayer.seekToNext(); } else { await _audioPlayer.seek(Duration.zero, index: 0); } }, ), ], ), const SizedBox(height: 20), SizedBox( width: 400, child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ const Icon(Icons.volume_up, color: Colors.white), SizedBox( width: 350, child: Slider( value: _volumeValue, min: 0.0, max: 1.0, onChanged: _setVolume, activeColor: Colors.white, inactiveColor: Colors.white54, ), ), ], ), ), const SizedBox(height: 20), Padding( padding: EdgeInsets.only(bottom: 20), child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ IconButton( onPressed: () async { await _audioPlayer.seek( null, index: Random().nextInt(songs.length), ); }, icon: Icon(Icons.casino), color: Colors.white, iconSize: 30, ), IconButton( onPressed: () async { await _audioPlayer.setShuffleModeEnabled( !_audioPlayer.shuffleModeEnabled, ); if (_audioPlayer.shuffleModeEnabled) { prevLoopMode = _audioPlayer.loopMode; await _audioPlayer.setLoopMode(LoopMode.all); } else { await _audioPlayer.setLoopMode(prevLoopMode); } setState(() {}); }, icon: Icon( _audioPlayer.shuffleModeEnabled ? Icons.shuffle : Icons.double_arrow, ), color: Colors.white, iconSize: 30, ), IconButton( onPressed: _audioPlayer.shuffleModeEnabled ? null : (() async { int loopMode = loopModeToIndex[_audioPlayer.loopMode]!; loopMode = (loopMode + 1) % 3; await _audioPlayer.setLoopMode( indexToLoopMode[loopMode]!, ); setState(() {}); }), icon: icons[loopModeToIndex[_audioPlayer.loopMode]!], color: colors[loopModeToIndex[_audioPlayer.loopMode]!], iconSize: 30, ), ], ), ), ], ), ); }
String _formatDuration(Duration duration) { final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0'); final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0'); return '$minutes:$seconds'; }}7 collapsed lines
class DurationState { final Duration position; final Duration total;
DurationState({required this.position, required this.total});}
Explanation
- 因為 Dart 中的窮舉不像 TypeScript 中擁有直接雙向映射,enchanced enum 個人感覺還是有點麻煩,所以直接使用雙
HashMap
進行處理會比較快。- 用以切換循環模式按鈕樣式
- 儲存目前是否在切換歌曲播放秒數和要切換到歌曲秒數
Methods
import 'dart:async';2 collapsed lines
import 'dart:collection';import 'dart:math';
import 'package:flutter/material.dart';import 'package:just_audio/just_audio.dart';2 collapsed lines
import 'package:just_audio_background/just_audio_background.dart';import 'package:musicplayer/song.dart';import 'package:rxdart/rxdart.dart';import 'package:volume_controller/volume_controller.dart';
35 collapsed lines
Future<void> main() async { VolumeController.instance.showSystemUI = false; await JustAudioBackground.init(); runApp(const MyApp());}
class MyApp extends StatelessWidget { const MyApp({super.key});
@override Widget build(BuildContext context) { return MaterialApp( title: 'Music App', theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), scaffoldBackgroundColor: Colors.black, textTheme: const TextTheme( bodyMedium: TextStyle(color: Colors.white), headlineMedium: TextStyle(color: Colors.white), ), ), debugShowCheckedModeBanner: false, home: const MyHomePage(title: 'Music App'), ); }}
class MyHomePage extends StatefulWidget { const MyHomePage({super.key, required this.title});
final String title;
@override State<MyHomePage> createState() => _MyHomePageState();}
class _MyHomePageState extends State<MyHomePage> {56 collapsed lines
final AudioPlayer _audioPlayer = AudioPlayer(); late final VolumeController _volumeController; late final StreamSubscription<double> _subscription; final HashMap<LoopMode, int> loopModeToIndex = HashMap.of({ LoopMode.off: 0, LoopMode.all: 1, LoopMode.one: 2, }); final HashMap<int, LoopMode> indexToLoopMode = HashMap.of({ 0: LoopMode.off, 1: LoopMode.all, 2: LoopMode.one, }); List<Color> colors = [Colors.grey.shade700, Colors.white, Colors.white]; List<Icon> icons = [ const Icon(Icons.repeat), const Icon(Icons.repeat), const Icon(Icons.repeat_one), ]; List<Song> songs = [ Song( title: 'Numb', artist: 'Will Pan', imageName: 'IMG_6398.jpg', audioName: 'Numb.mp3', ), Song( title: 'Colorful', artist: 'Hiiragi Magnetite', imageName: 'IMG_6399.jpg', audioName: 'カラフル.mp3', ), Song( title: 'もしも', artist: 'ナナツカゼ', imageName: 'IMG_6400.jpg', audioName: 'What If.mp3', ), Song( title: 'サンダーソニア', artist: '小花依ある', imageName: 'IMG_6412.jpg', audioName: 'サンダーソニア.mp3', ), Song( title: 'I Am I Can', artist: 'Nine Chen', imageName: 'IMG_6413.jpg', audioName: 'I am I can.mp3', ), ]; LoopMode prevLoopMode = LoopMode.off; double _volumeValue = 0; Duration _currentPosition = Duration.zero; bool changingPosition = false;
_MyHomePageState() { _audioPlayer.setVolume(1.0); _audioPlayer.setLoopMode(LoopMode.off); _audioPlayer.setShuffleModeEnabled(false); _audioPlayer.setAudioSources( songs.map((song) { return AudioSource.uri( Uri.parse( 'https://homeworkfileserver.onrender.com/music/${song.audioName}', ), tag: MediaItem( id: song.audioName, title: song.title, artist: song.artist, artUri: Uri.parse( 'https://homeworkfileserver.onrender.com/image/${song.imageName}', ), ), ); }).toList(), initialIndex: 0, initialPosition: Duration.zero, preload: true, ); _audioPlayer.sequenceStateStream.listen((state) { if (state.currentIndex != null) { setState(() {}); } }); _audioPlayer.playerStateStream.listen((state) async { if (state.processingState == ProcessingState.completed) { if (!_audioPlayer.hasNext && _audioPlayer.loopMode == LoopMode.off && _audioPlayer.shuffleModeEnabled == false) { await _audioPlayer.seek(Duration.zero, index: 0); await _audioPlayer.stop(); } } setState(() {}); }); _volumeController = VolumeController.instance;
_subscription = _volumeController.addListener((volume) { setState(() => _volumeValue = volume); }, fetchInitialVolume: true); }
Stream<DurationState> get _durationStateStream => Rx.combineLatest2<Duration, Duration?, DurationState>( _audioPlayer.positionStream, _audioPlayer.durationStream, (position, duration) => DurationState(position: position, total: duration ?? Duration.zero), );
@override void dispose() { _audioPlayer.dispose(); super.dispose(); }
void _playPauseAudio() async { if (_audioPlayer.playing) { await _audioPlayer.pause(); } else { await _audioPlayer.play(); } }
void _setVolume(double volume) async { await _volumeController.setVolume(volume); setState(() {}); }
228 collapsed lines
@override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( backgroundColor: Colors.black, title: Text(widget.title, style: const TextStyle(color: Colors.white)), ), body: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Padding( padding: EdgeInsets.all(16.0), child: Text( 'Now Playing', style: TextStyle( fontSize: 28, fontWeight: FontWeight.bold, color: Colors.white, ), ), ), Padding( padding: EdgeInsets.symmetric(horizontal: 16.0), child: Text( '${songs[_audioPlayer.currentIndex ?? 0].artist} - ${songs[_audioPlayer.currentIndex ?? 0].title}', style: TextStyle(fontSize: 20, color: Colors.white70), textAlign: TextAlign.center, ), ), const SizedBox(height: 20), SizedBox( width: 300, height: 300, child: ClipRRect( borderRadius: BorderRadius.circular(16.0), child: Image.network( "https://homeworkfileserver.onrender.com/image/${songs[_audioPlayer.currentIndex ?? 0].imageName}", fit: BoxFit.cover, ), ), ), const SizedBox(height: 30), StreamBuilder<DurationState>( stream: _durationStateStream, builder: (context, snapshot) { final durationState = snapshot.data; final position = durationState?.position ?? Duration.zero; final total = durationState?.total ?? Duration.zero;
return Column( children: [ SizedBox( width: 400, child: Slider( value: changingPosition ? _currentPosition.inSeconds.toDouble() : min( total.inSeconds.toDouble(), position.inSeconds.toDouble(), ), max: total.inSeconds.toDouble(), onChangeStart: (value) { changingPosition = true; _currentPosition = position; }, onChanged: (value) { _currentPosition = Duration(seconds: value.toInt()); }, onChangeEnd: (value) async { await _audioPlayer.seek( Duration(seconds: value.toInt()), ); changingPosition = false; }, activeColor: Colors.white, inactiveColor: Colors.white54, ), ), SizedBox( width: 400, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( _formatDuration( changingPosition ? _currentPosition : position, ), style: const TextStyle(color: Colors.white), ), Text( _formatDuration(total), style: const TextStyle(color: Colors.white), ), ], ), ), ), ], ); }, ), const SizedBox(height: 30), Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ IconButton( icon: const Icon(Icons.skip_previous, color: Colors.white), iconSize: 48, onPressed: () async { if (_audioPlayer.hasPrevious) { await _audioPlayer.seekToPrevious(); } else { await _audioPlayer.seek( Duration.zero, index: songs.length - 1, ); } }, ), IconButton( icon: Icon( _audioPlayer.playing ? Icons.pause_circle_filled : Icons.play_circle_filled, color: Colors.white, ), iconSize: 64, onPressed: _playPauseAudio, ), IconButton( icon: const Icon(Icons.skip_next, color: Colors.white), iconSize: 48, onPressed: () async { if (_audioPlayer.hasNext) { await _audioPlayer.seekToNext(); } else { await _audioPlayer.seek(Duration.zero, index: 0); } }, ), ], ), const SizedBox(height: 20), SizedBox( width: 400, child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ const Icon(Icons.volume_up, color: Colors.white), SizedBox( width: 350, child: Slider( value: _volumeValue, min: 0.0, max: 1.0, onChanged: _setVolume, activeColor: Colors.white, inactiveColor: Colors.white54, ), ), ], ), ), const SizedBox(height: 20), Padding( padding: EdgeInsets.only(bottom: 20), child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ IconButton( onPressed: () async { await _audioPlayer.seek( null, index: Random().nextInt(songs.length), ); }, icon: Icon(Icons.casino), color: Colors.white, iconSize: 30, ), IconButton( onPressed: () async { await _audioPlayer.setShuffleModeEnabled( !_audioPlayer.shuffleModeEnabled, ); if (_audioPlayer.shuffleModeEnabled) { prevLoopMode = _audioPlayer.loopMode; await _audioPlayer.setLoopMode(LoopMode.all); } else { await _audioPlayer.setLoopMode(prevLoopMode); } setState(() {}); }, icon: Icon( _audioPlayer.shuffleModeEnabled ? Icons.shuffle : Icons.double_arrow, ), color: Colors.white, iconSize: 30, ), IconButton( onPressed: _audioPlayer.shuffleModeEnabled ? null : (() async { int loopMode = loopModeToIndex[_audioPlayer.loopMode]!; loopMode = (loopMode + 1) % 3; await _audioPlayer.setLoopMode( indexToLoopMode[loopMode]!, ); setState(() {}); }), icon: icons[loopModeToIndex[_audioPlayer.loopMode]!], color: colors[loopModeToIndex[_audioPlayer.loopMode]!], iconSize: 30, ), ], ), ), ], ), ); }
String _formatDuration(Duration duration) { final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0'); final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0'); return '$minutes:$seconds'; }}
class DurationState { final Duration position; final Duration total;
DurationState({required this.position, required this.total});}
Explanation
- 初始化音樂播放器,並設定初始狀態與歌單。
- 抓取系統的音量控制器,並監聽音量變化。
- 將目前位置與總長度的 stream 合併成一個 stream,並且在兩個 stream 內皆有資料時才會進行輸出,此部分為 ReactiveX 相關語法,若此寫法會感覺抽象的話,請參考 ReactiveX 官網 或者閱讀「打通 RxJS 任督二脈」系列文章了解相關概念。
- 取得分跟秒,
padLeft
方法用於檢查字串長度是否為指定長度,若沒有達到,則會在左側填入第二個參數的值直至達到指定長度。
Widget
47 collapsed lines
import 'dart:async';import 'dart:collection';import 'dart:math';
import 'package:flutter/material.dart';import 'package:just_audio/just_audio.dart';import 'package:just_audio_background/just_audio_background.dart';import 'package:musicplayer/song.dart';import 'package:rxdart/rxdart.dart';import 'package:volume_controller/volume_controller.dart';
Future<void> main() async { VolumeController.instance.showSystemUI = false; await JustAudioBackground.init(); runApp(const MyApp());}
class MyApp extends StatelessWidget { const MyApp({super.key});
@override Widget build(BuildContext context) { return MaterialApp( title: 'Music App', theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), scaffoldBackgroundColor: Colors.black, textTheme: const TextTheme( bodyMedium: TextStyle(color: Colors.white), headlineMedium: TextStyle(color: Colors.white), ), ), debugShowCheckedModeBanner: false, home: const MyHomePage(title: 'Music App'), ); }}
class MyHomePage extends StatefulWidget { const MyHomePage({super.key, required this.title});
final String title;
@override State<MyHomePage> createState() => _MyHomePageState();}
class _MyHomePageState extends State<MyHomePage> {130 collapsed lines
final AudioPlayer _audioPlayer = AudioPlayer(); late final VolumeController _volumeController; late final StreamSubscription<double> _subscription; final HashMap<LoopMode, int> loopModeToIndex = HashMap.of({ LoopMode.off: 0, LoopMode.all: 1, LoopMode.one: 2, }); final HashMap<int, LoopMode> indexToLoopMode = HashMap.of({ 0: LoopMode.off, 1: LoopMode.all, 2: LoopMode.one, }); List<Color> colors = [Colors.grey.shade700, Colors.white, Colors.white]; List<Icon> icons = [ const Icon(Icons.repeat), const Icon(Icons.repeat), const Icon(Icons.repeat_one), ]; List<Song> songs = [ Song( title: 'Numb', artist: 'Will Pan', imageName: 'IMG_6398.jpg', audioName: 'Numb.mp3', ), Song( title: 'Colorful', artist: 'Hiiragi Magnetite', imageName: 'IMG_6399.jpg', audioName: 'カラフル.mp3', ), Song( title: 'もしも', artist: 'ナナツカゼ', imageName: 'IMG_6400.jpg', audioName: 'What If.mp3', ), Song( title: 'サンダーソニア', artist: '小花依ある', imageName: 'IMG_6412.jpg', audioName: 'サンダーソニア.mp3', ), Song( title: 'I Am I Can', artist: 'Nine Chen', imageName: 'IMG_6413.jpg', audioName: 'I am I can.mp3', ), ]; LoopMode prevLoopMode = LoopMode.off; double _volumeValue = 0; Duration _currentPosition = Duration.zero; bool changingPosition = false;
_MyHomePageState() { _audioPlayer.setVolume(1.0); _audioPlayer.setLoopMode(LoopMode.off); _audioPlayer.setShuffleModeEnabled(false); _audioPlayer.setAudioSources( songs.map((song) { return AudioSource.uri( Uri.parse( 'https://homeworkfileserver.onrender.com/music/${song.audioName}', ), tag: MediaItem( id: song.audioName, title: song.title, artist: song.artist, artUri: Uri.parse( 'https://homeworkfileserver.onrender.com/image/${song.imageName}', ), ), ); }).toList(), initialIndex: 0, initialPosition: Duration.zero, preload: true, ); _audioPlayer.sequenceStateStream.listen((state) { if (state.currentIndex != null) { setState(() {}); } }); _audioPlayer.playerStateStream.listen((state) async { if (state.processingState == ProcessingState.completed) { if (!_audioPlayer.hasNext && _audioPlayer.loopMode == LoopMode.off && _audioPlayer.shuffleModeEnabled == false) { await _audioPlayer.seek(Duration.zero, index: 0); await _audioPlayer.stop(); } } setState(() {}); }); _volumeController = VolumeController.instance;
_subscription = _volumeController.addListener((volume) { setState(() => _volumeValue = volume); }, fetchInitialVolume: true); }
Stream<DurationState> get _durationStateStream => Rx.combineLatest2<Duration, Duration?, DurationState>( _audioPlayer.positionStream, _audioPlayer.durationStream, (position, duration) => DurationState(position: position, total: duration ?? Duration.zero), );
@override void dispose() { _audioPlayer.dispose(); super.dispose(); }
void _playPauseAudio() async { if (_audioPlayer.playing) { await _audioPlayer.pause(); } else { await _audioPlayer.play(); } }
void _setVolume(double volume) async { await _volumeController.setVolume(volume); setState(() {}); }
@override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( backgroundColor: Colors.black, title: Text(widget.title, style: const TextStyle(color: Colors.white)), ), body: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Padding( padding: EdgeInsets.all(16.0), child: Text( 'Now Playing', style: TextStyle( fontSize: 28, fontWeight: FontWeight.bold, color: Colors.white, ), ), ), Padding( padding: EdgeInsets.symmetric(horizontal: 16.0), child: Text( '${songs[_audioPlayer.currentIndex ?? 0].artist} - ${songs[_audioPlayer.currentIndex ?? 0].title}', style: TextStyle(fontSize: 20, color: Colors.white70), textAlign: TextAlign.center, ), ), const SizedBox(height: 20), SizedBox( width: 300, height: 300, child: ClipRRect( borderRadius: BorderRadius.circular(16.0), child: Image.network( "https://homeworkfileserver.onrender.com/image/${songs[_audioPlayer.currentIndex ?? 0].imageName}", fit: BoxFit.cover, ), ), ), const SizedBox(height: 30), StreamBuilder<DurationState>( stream: _durationStateStream, builder: (context, snapshot) { final durationState = snapshot.data; final position = durationState?.position ?? Duration.zero; final total = durationState?.total ?? Duration.zero;
return Column( children: [ SizedBox( width: 400, child: Slider( value: changingPosition ? _currentPosition.inSeconds.toDouble() : min( total.inSeconds.toDouble(), position.inSeconds.toDouble(), ), max: total.inSeconds.toDouble(), onChangeStart: (value) { changingPosition = true; _currentPosition = position; }, onChanged: (value) { _currentPosition = Duration(seconds: value.toInt()); }, onChangeEnd: (value) async { await _audioPlayer.seek( Duration(seconds: value.toInt()), ); changingPosition = false; }, activeColor: Colors.white, inactiveColor: Colors.white54, ), ), SizedBox( width: 400, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( _formatDuration( changingPosition ? _currentPosition : position, ), style: const TextStyle(color: Colors.white), ), Text( _formatDuration(total), style: const TextStyle(color: Colors.white), ), ], ), ), ), ], ); }, ), const SizedBox(height: 30), Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ IconButton( icon: const Icon(Icons.skip_previous, color: Colors.white), iconSize: 48, onPressed: () async { if (_audioPlayer.hasPrevious) { await _audioPlayer.seekToPrevious(); } else { await _audioPlayer.seek( Duration.zero, index: songs.length - 1, ); } }, ), IconButton( icon: Icon( _audioPlayer.playing ? Icons.pause_circle_filled : Icons.play_circle_filled, color: Colors.white, ), iconSize: 64, onPressed: _playPauseAudio, ), IconButton( icon: const Icon(Icons.skip_next, color: Colors.white), iconSize: 48, onPressed: () async { if (_audioPlayer.hasNext) { await _audioPlayer.seekToNext(); } else { await _audioPlayer.seek(Duration.zero, index: 0); } }, ), ], ), const SizedBox(height: 20), SizedBox( width: 400, child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ const Icon(Icons.volume_up, color: Colors.white), SizedBox( width: 350, child: Slider( value: _volumeValue, min: 0.0, max: 1.0, onChanged: _setVolume, activeColor: Colors.white, inactiveColor: Colors.white54, ), ), ], ), ), const SizedBox(height: 20), Padding( padding: EdgeInsets.only(bottom: 20), child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ IconButton( onPressed: () async { await _audioPlayer.seek( null, index: Random().nextInt(songs.length), ); }, icon: Icon(Icons.casino), color: Colors.white, iconSize: 30, ), IconButton( onPressed: () async { await _audioPlayer.setShuffleModeEnabled( !_audioPlayer.shuffleModeEnabled, ); if (_audioPlayer.shuffleModeEnabled) { prevLoopMode = _audioPlayer.loopMode; await _audioPlayer.setLoopMode(LoopMode.all); } else { await _audioPlayer.setLoopMode(prevLoopMode); } setState(() {}); }, icon: Icon( _audioPlayer.shuffleModeEnabled ? Icons.shuffle : Icons.double_arrow, ), color: Colors.white, iconSize: 30, ), IconButton( onPressed: _audioPlayer.shuffleModeEnabled ? null : (() async { int loopMode = loopModeToIndex[_audioPlayer.loopMode]!; loopMode = (loopMode + 1) % 3; await _audioPlayer.setLoopMode( indexToLoopMode[loopMode]!, ); setState(() {}); }), icon: icons[loopModeToIndex[_audioPlayer.loopMode]!], color: colors[loopModeToIndex[_audioPlayer.loopMode]!], iconSize: 30, ), ], ), ), ], ), ); }
5 collapsed lines
String _formatDuration(Duration duration) { final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0'); final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0'); return '$minutes:$seconds'; }}
6 collapsed lines
class DurationState { final Duration position; final Duration total;
DurationState({required this.position, required this.total});}
Explanation
- 確認目前使用者是否有在更改播放位置,若有則將時間與滑桿值設定為使用者目前滑到的位置
- 在滑動滑桿時,使音樂不會停止,讓使用者先滑到想聽的地方並鬆開滑桿後,才將音樂位置移到指定位置。
- 為了讓打亂歌單循環播放,所以當打亂模式開啟時,強制鎖定成循環播放,待使用者關閉打亂模式後,再恢復原本的播放模式。
- 依據隨機歌單模式決定切換循環模式鈕是否啟用。在 listener 為
null
時,按鈕會被設定為禁用。
App Exhibition
展示功能:播放音樂、播放暫停、下一首、上一首、歌曲相關訊息展示、隨機播放一首、調整系統音量、自動播放下一首、顯示目前播放時間、滑動調整、打亂歌單、單曲循環、歌單循環
展示功能:背景播放與背景控制
Wrapping Up
雖然說可能又是有點急就章的一次,但是感謝 AI 幫助,讓我寫介面可以寫那麼快。並且這次我也是見識到了 just_audio 模組有多麼強大,作者已經把東西都抽象好了,你只要接上去就好,不用再去重新實作相關的程式碼,真的是非常的方便。