Never give up

Flutter - audio plugin test 본문

해왔던 삽질..

Flutter - audio plugin test

대기만성 개발자 2021. 2. 14. 01:47
반응형

music player를 한번 만들어보려고 관련 패키지를 찾아보던 도중

 

audioplayers와 audio query가 보여서 한번 적용을 해봤습니다

 

audioplayers : pub.dev/packages/audioplayers

audio query : pub.dev/packages/flutter_audio_query

 

splash

class SplashScreen extends StatefulWidget {
  @override
  _SplashScreenState createState() => _SplashScreenState();
}

class _SplashScreenState extends State<SplashScreen> {
  @override
  void initState() {
    super.initState();
    initData();
  }

  void initData() async {
    PlayListData data = PlayListData();
    await data.initData();

    MusicControl control = MusicControl();
    await control.initMusicList(data.getSongs);

    Navigator.pushAndRemoveUntil(
        context, MaterialPageRoute(builder: (context) => MainScreen()), (
        route) => false);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        backgroundColor: Colors.redAccent,
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              CircleAvatar(
                radius: 60,
                backgroundColor: Colors.white,
                child: OffsetAnimation(
                    duration: 700,
                    showWidget: Icon(Icons.music_note,
                        color: Colors.redAccent, size: 40)),
              ),
              SizedBox(height: 20),
              OpacityAnimation(
                begin: 0.4,
                duration: 1000,
                showWidget: Text('Now loading...',
                    style: TextStyle(
                        fontSize: 30,
                        color: Colors.white,
                        fontWeight: FontWeight.bold,
                        shadows: [
                          Shadow(
                              offset: Offset(3, 3),
                              blurRadius: 4,
                              color: Colors.grey)
                        ])),
              )
            ],
          ),
        ));
  }
}

animation

class OpacityAnimation extends StatefulWidget {
  final Widget showWidget;
  final bool repeat;
  final int duration;
  final double begin;

  OpacityAnimation(
      {@required this.showWidget, this.repeat = true, this.duration = 500, this.begin = 0});

  @override
  _OpacityAnimationState createState() => _OpacityAnimationState();
}

class _OpacityAnimationState extends State<OpacityAnimation>
    with SingleTickerProviderStateMixin {
  AnimationController _controller;

  @override
  void initState() {
    super.initState();

    _controller = AnimationController(
        vsync: this, duration: Duration(milliseconds: widget.duration));
    if (widget.repeat) {
      _controller.repeat(reverse: true);
    } else {
      _controller.forward();
    }
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return FadeTransition(
        opacity: Tween(begin: widget.begin, end: 1.0).animate(
            CurvedAnimation(parent: _controller, curve: Curves.decelerate)),
        child: widget.showWidget);
  }
}

class OffsetAnimation extends StatefulWidget {
  final Widget showWidget;
  final bool repeat;
  final int duration;

  OffsetAnimation(
      {@required this.showWidget, this.repeat = true, this.duration = 500});

  @override
  _OffsetAnimationState createState() => _OffsetAnimationState();
}

class _OffsetAnimationState extends State<OffsetAnimation>
    with SingleTickerProviderStateMixin {
  AnimationController _controller;

  @override
  void initState() {
    super.initState();

    _controller = AnimationController(
        vsync: this, duration: Duration(milliseconds: widget.duration));
    if (widget.repeat) {
      _controller.repeat(reverse: true);
    } else {
      _controller.forward();
    }
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
        animation: _controller,
        builder: (context, child) {
          return Transform.translate(
              offset: Offset(0, 10 * (1 - _controller.value)),
              child: widget.showWidget);
        });
  }
}

main

class MainScreen extends StatefulWidget {
  @override
  _MainScreenState createState() => _MainScreenState();
}

class _MainScreenState extends State<MainScreen> {
  final PlayListData _data = PlayListData();
  final MusicControl _control = MusicControl();
  bool _isSearching = false;

  @override
  Widget build(BuildContext context) {
    FocusScopeNode scope = FocusScope.of(context);
    return WillPopScope(
      onWillPop: () {
        _control.closeStream();
        SystemNavigator.pop();
        return;
      },
      child: Scaffold(
        appBar: AppBar(
            title: _isSearching
                ? TextField(
                    autofocus: true,
                    decoration: InputDecoration(
                        border: InputBorder.none,
                        hintText: 'Searching...',
                        hintStyle: TextStyle(color: Colors.white),
                        suffixIcon: IconButton(
                            icon: const Icon(Icons.clear, color: Colors.white),
                            onPressed: () {
                              if (scope.hasFocus) {
                                scope.unfocus();

                                setState(() {
                                  _isSearching = false;
                                });
                              }
                            })),
                    onChanged: (value) {
                      setState(() {
                        _data.searchSong(value);
                      });
                    },
                  )
                : Text('Music player'),
            backgroundColor: Colors.redAccent,
            actions: [
              _isSearching
                  ? Container()
                  : IconButton(
                      icon: Icon(Icons.search),
                      onPressed: () {
                        setState(() {
                          _isSearching = true;
                        });
                      })
            ]),
        body: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Expanded(
                child: InkWell(
              onTap: () {
                if (scope.hasFocus) {
                  scope.unfocus();

                  setState(() {
                    _isSearching = false;
                  });
                }
              },
              child: StreamBuilder<int>(
                  stream: _control.getCurrentIndexStream,
                  initialData: 0,
                  builder: (context, snapshot) {
                    if (snapshot.hasData) {
                      return _isSearching
                          ? _listView(list: _data.getSearched)
                          : _listView(
                              list: _data.getSongs, songIndex: snapshot.data);
                    }

                    return Center(child: Text('Error'));
                  }),
            )),
            PlayStatus()
          ],
        ),
      ),
    );
  }

  ListView _listView({List<SongInfo> list, int songIndex = -1}) {
    return ListView.separated(
        shrinkWrap: true,
        itemCount: list.length,
        itemBuilder: (context, index) {
          var item = list[index];
          return ListTile(
              leading: CircleAvatar(
                  backgroundColor: Colors.white60,
                  child: (item.albumArtwork == null)
                      ? Icon(Icons.music_note)
                      : Image.asset(item.albumArtwork)),
              title: Text(item.title,
                  style: (songIndex == index)
                      ? TextStyle(
                          color: Colors.white, fontWeight: FontWeight.bold)
                      : TextStyle(),
                  maxLines: 1,
                  overflow: TextOverflow.ellipsis),
              trailing: Text(item.duration.getTime),
              onTap: () => _setSong(index));
        },
        separatorBuilder: (context, index) => Padding(
            padding: const EdgeInsets.only(left: 80, right: 20),
            child: Divider(color: Colors.white60)));
  }

  void _setSong(int index) {
    _control.stop();
    _control.setIndex(index);
    _control.play();
  }
}

play status

class PlayStatus extends StatelessWidget {
  final MusicControl _control = MusicControl();

  @override
  Widget build(BuildContext context) {
    return Container(
      color: Colors.white12,
      child: Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
        Padding(
          padding: const EdgeInsets.symmetric(horizontal: 16),
          child: StreamBuilder<Duration>(
              stream: _control.getProgressbarStream,
              initialData: Duration.zero,
              builder: (context, snapshot) {
                if (snapshot.hasData) {
                  double _position = 0.0;
                  return Row(
                    children: [
                      Expanded(
                        child: Slider(
                          activeColor: Colors.redAccent,
                          inactiveColor: Colors.white70,
                          value: (snapshot.data != null &&
                                  snapshot.data.inMilliseconds /
                                          _control.getDuration <
                                      1.0)
                              ? snapshot.data.inMilliseconds /
                                  _control.getDuration
                              : 0.0,
                          onChanged: (value) {
                            _position = value * _control.getDuration;

                            _control.jumpTo(
                                Duration(milliseconds: _position.round()));
                          },
                        ),
                      ),
                      Text(snapshot.data.getTime)
                    ],
                  );
                }
                return const Center(child: Text('Error'));
              }),
        ),
        Padding(
          padding: const EdgeInsets.symmetric(vertical: 16),
          child: Row(
            mainAxisAlignment: MainAxisAlignment.spaceAround,
            children: <Widget>[
              StreamBuilder<bool>(
                  stream: _control.getRepeatStream,
                  initialData: true,
                  builder: (context, snapshot) {
                    if (snapshot.hasData) {
                      return IconButton(
                        icon: Icon(
                            _control.isLoop ? Icons.repeat : Icons.repeat_one),
                        onPressed: _control.toggleLoop,
                      );
                    }
                    return Center(child: Text('Error'));
                  }),
              IconButton(
                  iconSize: 36,
                  icon: Icon(Icons.skip_previous),
                  onPressed: () {
                    _control.previousIndex();
                  }),
              StreamBuilder<AudioPlayerState>(
                  stream: _control.getStateStream,
                  initialData: AudioPlayerState.STOPPED,
                  builder: (context, snapshot) {
                    if (snapshot.hasData) {
                      if (snapshot.data == AudioPlayerState.COMPLETED) {
                        _control.nextIndex();
                      }

                      return IconButton(
                        padding: EdgeInsets.zero,
                        icon: Icon(
                            (snapshot.data == AudioPlayerState.PLAYING)
                                ? Icons.pause
                                : Icons.play_arrow,
                            size: 48.0),
                        onPressed: () {
                          switch (snapshot.data) {
                            case AudioPlayerState.PAUSED:
                              _control.resume();
                              break;
                            case AudioPlayerState.STOPPED:
                              _control.play();
                              break;
                            case AudioPlayerState.PLAYING:
                              _control.pause();
                              break;
                            case AudioPlayerState.COMPLETED:
                              break;
                            default:
                              return;
                          }
                        },
                      );
                    }
                    return const Icon(Icons.play_arrow, size: 48.0);
                  }),
              IconButton(
                  iconSize: 36,
                  icon: const Icon(Icons.skip_next),
                  onPressed: () {
                    _control.nextIndex(nextEvent: true);
                  }),
              IconButton(
                  icon: const Icon(Icons.stop),
                  onPressed: () {
                    _control.stop();
                  }),
            ],
          ),
        ),
      ]),
    );
  }
}

playlist data

class PlayListData {
  static final PlayListData _playListData = PlayListData._instance();

  factory PlayListData() => _playListData;

  PlayListData._instance();

  final FlutterAudioQuery _audioQuery = FlutterAudioQuery();

  List<SongInfo> _allSongs = [];

  List<SongInfo> _searchedSongs = [];

  UnmodifiableListView<SongInfo> get getSongs =>
      UnmodifiableListView(_allSongs);

  UnmodifiableListView<SongInfo> get getSearched =>
      UnmodifiableListView(_searchedSongs);

  Future<void> initData() async {
/*
    List<AlbumInfo> albums =
        await _audioQuery.getAlbums(sortType: AlbumSortType.OLDEST_YEAR);

    albums.forEach((element) async {
      _allSongs
          .addAll(await _audioQuery.getSongsFromAlbum(albumId: element.id));
    });
*/
    _allSongs =
        await _audioQuery.getSongs(sortType: SongSortType.ALPHABETIC_ALBUM);
  }

  void searchSong(String name) {
    _searchedSongs = _allSongs
        .where((element) =>
            element.title.toUpperCase().contains(name.toUpperCase()))
        .toList();
  }
}

music control

class MusicControl {
  static final MusicControl _musicControl = MusicControl._instance();

  factory MusicControl() => _musicControl;

  MusicControl._instance();

  List<SongInfo> _playList;
  int _songIndex = 0;
  String _url;
  int _songDuration = Duration.zero.inMilliseconds;
  StreamController<int> _indexStream = StreamController<int>()..add(0);
  StreamController<bool> _repeatStream = StreamController<bool>()..add(true);
  bool _loop = true;

  final AudioPlayer _audioPlayer = AudioPlayer();

  Stream<AudioPlayerState> get getStateStream =>
      _audioPlayer.onPlayerStateChanged;

  Stream<Duration> get getProgressbarStream =>
      _audioPlayer.onAudioPositionChanged;

  Stream<int> get getCurrentIndexStream => _indexStream.stream;

  Stream<bool> get getRepeatStream => _repeatStream.stream;

  int get getDuration => _songDuration;

  bool get isLoop => _loop;

  void toggleLoop() {
    _loop = !_loop;
    _repeatStream.add(_loop);
  }

  Future<void> initMusicList(List<SongInfo> list) async {
    _playList = list ?? [];
  }

  void closeStream() {
    _indexStream?.close();
    _repeatStream?.close();
  }

  void setIndex(int index) {
    _songIndex = index;
    _indexStream.add(_songIndex);
  }

  void nextIndex({bool nextEvent = false}) {
    if (_songIndex < _playList.length - 1) {
      _songIndex++;
      stop();
      play();
    } else {
      stop();
      if(isLoop) {
        _songIndex = 0;
        play();
      } else if(nextEvent){
        _songIndex = 0;
        play();
      }
    }

    _indexStream.add(_songIndex);
  }

  void previousIndex() {
    if (_songIndex > 0) {
      _songIndex--;
    } else {
      _songIndex = _playList.length - 1;
    }
    stop();
    play();

    _indexStream.add(_songIndex);
  }

  void play() async {
    var item = _playList[_songIndex];

    _url = item.filePath;

    _songDuration = int.parse(item.duration);

    await _audioPlayer.play(_url).catchError((error) {
      print('error : $error');
    });
  }

  void pause() async {
    await _audioPlayer.pause().catchError((error) {
      print('error : $error');
    });
  }

  void stop() async {
    await _audioPlayer.stop().catchError((error) {
      print('error : $error');
    });
  }

  void jumpTo(Duration duration) async {
    await _audioPlayer.seek(duration).catchError((error) {
      print('error : $error');
    });
  }

  void resume() async {
    await _audioPlayer.resume().catchError((error) {
      print('error : $error');
    });
  }
}

extension

extension StringToTime on String {
  String get getTime {
    int ms = int.parse(this);
    Duration duration = Duration(milliseconds: ms);

    String twoDigits(int n) => n.toString().padLeft(2, "0");
    String twoDigitMinutes = twoDigits(duration.inMinutes.remainder(60));
    String twoDigitSeconds = twoDigits(duration.inSeconds.remainder(60));
    if (duration.inHours == 0) {
      return '$twoDigitMinutes:$twoDigitSeconds';
    } else {
      return "${twoDigits(duration.inHours)}:$twoDigitMinutes:$twoDigitSeconds";
    }
  }
}

extension DurationToTime on Duration {
  String get getTime {
    String twoDigits(int n) => n.toString().padLeft(2, "0");
    String twoDigitMinutes = twoDigits(this.inMinutes.remainder(60));
    String twoDigitSeconds = twoDigits(this.inSeconds.remainder(60));
    if (this.inHours == 0) {
      return '$twoDigitMinutes:$twoDigitSeconds';
    } else {
      return "${twoDigits(this.inHours)}:$twoDigitMinutes:$twoDigitSeconds";
    }
  }
}

추가로 안드로이드 버전이 올라가서 audio query를 사용할 때

 

manifest에서 application태그 안에 android:requestLegacyExternalStorage="true"를 꼭 넣어주셔야됩니다

<application
        android:name="io.flutter.app.FlutterApplication"
        android:label="앱 이름"
        android:icon="@mipmap/ic_launcher"
        android:requestLegacyExternalStorage="true"> //<- 요거!

 

 

결과 화면

< 노답 플레이어.jpg >

 

요번에는 상태관리는 다른것 없이 set state로 했고, singleton 패턴을 이용해서 구현해봤습니다

 

먼저 splash에서 audio query패키지를 이용해 SongInfo list를 가져옵니다

 

그 후 music controller의 playlist에 넣어주고 메인화면으로 넘어옵니다

 

 

 

가져온 list는 listview로 구현하는데 노래의 길이 값을 변환없이 그대로 가져오면

 

시간 그리고 초 이하 단위까지 나와서 String  extension을 이용해서 분, 초로 나올 수 있게 처리했습니다

 

그리고 streambuilder를 이용해서 현재 재생중인 노래를 강조해줍니다

(소스코드를 보시면 알겠지만 검색중에는 따로 강조를 하지 않고 있습니다)

 

 

 

playstatus부분에서는 갱신되는 부분이 많아서 전부 setState로 구현하면

 

rebuild도 많이 되고 그에 따른 성능저하 등을 고려해서 stream을 사용했습니다

 

사용한 부분으로는 slider와 text로 진행도 표시, 연속모드 전환, audio state으로

 

먼저 slider부분은 slider 특성상 double 값으로 0.0에서 1.0까지의 값을 갖는데

 

진행도를 나타내는 position stream값이 duration이기 때문에 milliseconds를 이용해서

 

변환을 해줘야되고 이 값을 전체 duration 값의 milliseconds값으로 나눠줘야 됩니다

 

수식으로 대충 표현해보면 value = position.ms / duration.ms = 0.0 ~ 1.0 이 되고

 

onChanged값에서 value 값이 duration을 곱하면 position값이 나오게 되겠죠

 

그리고 슬라이더를 이동시켰을 때에는 duration 값으로 넣어줘야되기 때문에

 

jumpTo 부분에 Duration(milliseconds)값을 넣어줍니다

 

그리고 slider 옆에 text값으로 현재 진행도를 표시해주는 부분은

 

전에 String값을 분,초로 표현해주는것과 동일한 방식으로 사용되었고

 

snapshot의 data변화(진행도)에 따라 시간이 갱신됩니다

 

 

연속모드는 뭐 간단하니 넘어가고..

 

 

이전곡, 다음곡 버튼 부분을 눌렀을 때 index만 변화시키고, stop, play만 해주면

 

list[index]값을 알아서 불러오기 때문에 간단하게 구현할 수 있습니다

 

그리고 state부분을 보면 시작 혹은 일시정지 버튼을 누를 때

 

state에 따라 각각 다른 동작을 취할 수 있게 해줬고

 

complete상태가 되었을 때 다음곡으로 이동할 수 있도록 해줬습니다

 

반복모드가 아닐 때에는 마지막 곡에서 멈출수 있도록 해줬습니다

 

 

 

 

 

-- 이하 밑의 부분은 필자의 개소리(?)를 늘어놓을 예정이므로 예제만 보고싶으신 분은 스킵하시면 됩니다..

 

 

 

 

 

먼저 이 글을 쓴 목적으로는..

 

이 패키지는 버리고 다른 패키지를 찾아봐야겠다는 생각이 들었고

 

여기까지 해온게 좀 아깝기도 해서 그냥 포스팅용으로 대충 마무리 지을 예정으로 만들었습니다..

 

하루정도 걸리긴 했는데 패키지 찾는시간 또한 하루가 걸려서 시간도 아까웠고요..

 

먼저 audioplayers를 사용하게 된 이유는 구글링 하다보니

 

"이 패키지가 가장 문제없이 잘 돌아갔다"라는 글을 보기도 했고

 

likes 수도 가장 많아서 사용해봤습니다

 

근데 문제점으로 (사실 필자가 미숙한거긴 하지만..)

 

앱을 닫았을때 노래는 잘 나옵니다 그런데 다시 앱으로 돌아왔을 때

 

position stream을 불러오지 못하고 play를 다시했을때 음악이 겹치는 현상이 생기더군요

 

background에서 돌아가던 노래 + foreground에서 새로 시작하는 노래가 겹친거죠

 

그리고 background처리를 따로 해주지 않아서 당연하게도

 

background상태에서 현재곡이 끝나면 다음곡으로 이동도 못합니다..

 

< 끝없는 필자의 노답 개발실력과 삽질.. ㅠㅠ >

이건 경험 부족이긴 한데.. api문서를 봐도 도저히 모르겠더군요..

 

아마도 audio caches부분을 이용하는거 같긴 한데 자료가 너무 없습니다..

 

그런데 background service관련해서 찾다보니 이런 패키지와 예제가 있더군요

 

(링크 : pub.dev/packages/flutter_background_service)

 

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  FlutterBackgroundService.initialize(onStart);

  runApp(MyApp());
}

void onStart() {
  WidgetsFlutterBinding.ensureInitialized();

  final audioPlayer = AudioPlayer();

  String url =
      "https://www.mediacollege.com/downloads/sound-effects/nature/forest/rainforest-ambient.mp3";

  audioPlayer.onPlayerStateChanged.listen((event) {
    if (event == AudioPlayerState.COMPLETED) {
      audioPlayer.play(url); // repeat
    }
  });

  audioPlayer.play(url);
}

아마도 이거 활용하면 될거 같은데..  onstart부분에 url값을 어떻게 불러와줘야될지 일단 고민이 되고

 

불러 오고나서 처리를 어떻게 해줘야될지 막막합니다..

 

추측으로는 list를 어떻게든(?) 넣어주고..

 

state complete될 때마다 index변화시켜 주면서 play시켜주면 될지도!?

< 100% 안돼 >

background stream 관련 issue로 다른 사람들의 질답도 봤었는데

 

screen keep on을 쓰면 된다, 다른 패키지를 이용하는게 낫다고 하는데

 

전자는 해봤는데 안되고 후자는 아직 잘 모르겠습니다

 

구글도 일을 안해서 두번째 앱 런칭도 늦어지고

 

다른 예제 새로 해볼려고 했는데 마음대로 안되서 씁쓸하네요..

 

< 필자의 상태 >

반응형
Comments