basic music playback implemented

This commit is contained in:
Moritz Weber 2020-04-06 22:33:26 +02:00
parent 10f9fe19f2
commit 13edbcefd2
16 changed files with 412 additions and 41 deletions

View file

@ -1,21 +1,13 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.mosh">
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example.mosh">
<!-- io.flutter.app.FlutterApplication is an android.app.Application that
calls FlutterMain.startInitialization(this); in its onCreate method.
In most cases you can leave this as-is, but you if you want to provide
additional functionality it is fine to subclass or reimplement
FlutterApplication and put your custom class here. -->
<application
android:name="io.flutter.app.FlutterApplication"
android:label="mosh"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<application android:name="io.flutter.app.FlutterApplication" android:label="mosh" android:icon="@mipmap/ic_launcher">
<activity android:name=".MainActivity" android:launchMode="singleTop" android:theme="@style/LaunchTheme" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
@ -23,8 +15,18 @@
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<meta-data android:name="flutterEmbedding" android:value="2" />
<service android:name="com.ryanheise.audioservice.AudioService">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
<receiver android:name="androidx.media.session.MediaButtonReceiver">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
</application>
</manifest>

View file

@ -4,3 +4,8 @@ abstract class Failure extends Equatable {
@override
List<Object> get props => [];
}
class IndexFailure extends Failure {
@override
List<Object> get props => [];
}

View file

@ -0,0 +1,8 @@
import 'package:dartz/dartz.dart';
import '../../core/error/failures.dart';
import '../entities/song.dart';
abstract class AudioRepository {
Future<Either<Failure, void>> playSong(int index, List<Song> songList);
}

View file

@ -0,0 +1,28 @@
import 'package:dartz/dartz.dart';
import 'package:equatable/equatable.dart';
import 'package:mosh/domain/repositories/audio_repository.dart';
import '../../core/error/failures.dart';
import '../../core/usecase.dart';
import '../entities/song.dart';
class PlaySong implements UseCase<void, Params> {
PlaySong(this.audioRepository);
final AudioRepository audioRepository;
@override
Future<Either<Failure, void>> call(Params params) async {
return audioRepository.playSong(params.index, params.songList);
}
}
class Params extends Equatable {
const Params(this.index, this.songList);
final int index;
final List<Song> songList;
@override
List<Object> get props => [index, songList];
}

View file

@ -1,9 +1,7 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_audio_query/flutter_audio_query.dart';
import 'package:mosh/system/datasources/local_music_fetcher.dart';
import 'package:mosh/system/datasources/moor_music_data_source.dart';
import 'package:mosh/system/repositories/music_data_repository_impl.dart';
import 'package:provider/provider.dart';
import 'presentation/pages/home_page.dart';
@ -11,7 +9,13 @@ import 'presentation/pages/library_page.dart';
import 'presentation/pages/settings_page.dart';
import 'presentation/state/music_store.dart';
import 'presentation/theming.dart';
import 'presentation/widgets/audio_service_widget.dart';
import 'presentation/widgets/navbar.dart';
import 'system/datasources/audio_manager.dart';
import 'system/datasources/local_music_fetcher.dart';
import 'system/datasources/moor_music_data_source.dart';
import 'system/repositories/audio_repository_impl.dart';
import 'system/repositories/music_data_repository_impl.dart';
void main() => runApp(MyApp());
@ -26,13 +30,16 @@ class MyApp extends StatelessWidget {
return MaterialApp(
title: 'mosh',
theme: theme(),
home: Provider<MusicStore>(
child: RootPage(),
home: AudioServiceWidget(
child: Provider<MusicStore>(
child: const RootPage(),
create: (BuildContext context) => MusicStore(
MusicDataRepositoryImpl(
musicDataRepository: MusicDataRepositoryImpl(
localMusicFetcher: LocalMusicFetcherImpl(FlutterAudioQuery()),
musicDataSource: MoorMusicDataSource(),
),
audioRepository: AudioRepositoryImpl(AudioManagerImpl()),
),
),
),
);
@ -40,7 +47,7 @@ class MyApp extends StatelessWidget {
}
class RootPage extends StatefulWidget {
RootPage({Key key}) : super(key: key);
const RootPage({Key key}) : super(key: key);
@override
_RootPageState createState() => _RootPageState();
@ -58,6 +65,8 @@ class _RootPageState extends State<RootPage> {
_musicStore.fetchAlbums();
_musicStore.fetchSongs();
AudioService.start(backgroundTaskEntrypoint: _backgroundTaskEntrypoint);
_pages = <Widget>[
HomePage(),
LibraryPage(
@ -72,6 +81,12 @@ class _RootPageState extends State<RootPage> {
super.didChangeDependencies();
}
@override
void dispose() {
AudioService.stop();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
@ -90,3 +105,7 @@ class _RootPageState extends State<RootPage> {
);
}
}
void _backgroundTaskEntrypoint() {
AudioServiceBackground.run(() => AudioPlayerTask());
}

View file

@ -1,3 +1,4 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:mobx/mobx.dart';
@ -45,7 +46,7 @@ class _SongsPageState extends State<SongsPage>
title: song.title,
subtitle: '${song.artist}${song.album}',
albumArtPath: song.albumArtPath,
onTap: () {},
onTap: () => _playSong(index, songs),
);
},
separatorBuilder: (BuildContext context, int index) => const Divider(
@ -58,4 +59,9 @@ class _SongsPageState extends State<SongsPage>
@override
bool get wantKeepAlive => true;
void _playSong(int index, List<Song> songList) {
widget.store.playSong(index, songList);
// AudioService.playFromMediaId(songList[index].path);
}
}

View file

@ -1,9 +1,12 @@
import 'package:dartz/dartz.dart';
import 'package:meta/meta.dart';
import 'package:mobx/mobx.dart';
import 'package:mosh/domain/usecases/play_song.dart';
import '../../core/error/failures.dart';
import '../../domain/entities/album.dart';
import '../../domain/entities/song.dart';
import '../../domain/repositories/audio_repository.dart';
import '../../domain/repositories/music_data_repository.dart';
import '../../domain/usecases/get_albums.dart';
import '../../domain/usecases/get_songs.dart';
@ -12,19 +15,21 @@ import '../../domain/usecases/update_database.dart';
part 'music_store.g.dart';
class MusicStore extends _MusicStore with _$MusicStore {
MusicStore(MusicDataRepository musicDataRepository)
: super(musicDataRepository);
MusicStore({@required MusicDataRepository musicDataRepository, @required AudioRepository audioRepository})
: super(musicDataRepository, audioRepository);
}
abstract class _MusicStore with Store {
_MusicStore(MusicDataRepository _musicDataRepository)
_MusicStore(MusicDataRepository _musicDataRepository, AudioRepository _audioRepository)
: _updateDatabase = UpdateDatabase(_musicDataRepository),
_getAlbums = GetAlbums(_musicDataRepository),
_getSongs = GetSongs(_musicDataRepository);
_getSongs = GetSongs(_musicDataRepository),
_playSong = PlaySong(_audioRepository);
final UpdateDatabase _updateDatabase;
final GetAlbums _getAlbums;
final GetSongs _getSongs;
final PlaySong _playSong;
@observable
ObservableFuture<List<Album>> albumsFuture;
@ -76,4 +81,9 @@ abstract class _MusicStore with Store {
isFetchingSongs = false;
}
@action
Future<void> playSong(int index, List<Song> songList) async {
await _playSong(Params(index, songList));
}
}

View file

@ -26,6 +26,40 @@ mixin _$MusicStore on _MusicStore, Store {
}, _$albumsFutureAtom, name: '${_$albumsFutureAtom.name}_set');
}
final _$songsAtom = Atom(name: '_MusicStore.songs');
@override
ObservableList<Song> get songs {
_$songsAtom.context.enforceReadPolicy(_$songsAtom);
_$songsAtom.reportObserved();
return super.songs;
}
@override
set songs(ObservableList<Song> value) {
_$songsAtom.context.conditionallyRunInAction(() {
super.songs = value;
_$songsAtom.reportChanged();
}, _$songsAtom, name: '${_$songsAtom.name}_set');
}
final _$isFetchingSongsAtom = Atom(name: '_MusicStore.isFetchingSongs');
@override
bool get isFetchingSongs {
_$isFetchingSongsAtom.context.enforceReadPolicy(_$isFetchingSongsAtom);
_$isFetchingSongsAtom.reportObserved();
return super.isFetchingSongs;
}
@override
set isFetchingSongs(bool value) {
_$isFetchingSongsAtom.context.conditionallyRunInAction(() {
super.isFetchingSongs = value;
_$isFetchingSongsAtom.reportChanged();
}, _$isFetchingSongsAtom, name: '${_$isFetchingSongsAtom.name}_set');
}
final _$isUpdatingDatabaseAtom = Atom(name: '_MusicStore.isUpdatingDatabase');
@override
@ -51,22 +85,31 @@ mixin _$MusicStore on _MusicStore, Store {
return _$updateDatabaseAsyncAction.run(() => super.updateDatabase());
}
final _$_MusicStoreActionController = ActionController(name: '_MusicStore');
final _$fetchAlbumsAsyncAction = AsyncAction('fetchAlbums');
@override
Future<void> fetchAlbums() {
final _$actionInfo = _$_MusicStoreActionController.startAction();
try {
return super.fetchAlbums();
} finally {
_$_MusicStoreActionController.endAction(_$actionInfo);
return _$fetchAlbumsAsyncAction.run(() => super.fetchAlbums());
}
final _$fetchSongsAsyncAction = AsyncAction('fetchSongs');
@override
Future<void> fetchSongs() {
return _$fetchSongsAsyncAction.run(() => super.fetchSongs());
}
final _$playSongAsyncAction = AsyncAction('playSong');
@override
Future<void> playSong(int index, List<Song> songList) {
return _$playSongAsyncAction.run(() => super.playSong(index, songList));
}
@override
String toString() {
final string =
'albumsFuture: ${albumsFuture.toString()},isUpdatingDatabase: ${isUpdatingDatabase.toString()}';
'albumsFuture: ${albumsFuture.toString()},songs: ${songs.toString()},isFetchingSongs: ${isFetchingSongs.toString()},isUpdatingDatabase: ${isUpdatingDatabase.toString()}';
return '{$string}';
}
}

View file

@ -0,0 +1,53 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
class AudioServiceWidget extends StatefulWidget {
const AudioServiceWidget({@required this.child});
final Widget child;
@override
_AudioServiceWidgetState createState() => _AudioServiceWidgetState();
}
class _AudioServiceWidgetState extends State<AudioServiceWidget>
with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
AudioService.connect();
}
@override
void dispose() {
AudioService.disconnect();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
switch (state) {
case AppLifecycleState.resumed:
AudioService.connect();
break;
case AppLifecycleState.paused:
AudioService.disconnect();
break;
default:
break;
}
}
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: () async {
AudioService.disconnect();
return true;
},
child: widget.child,
);
}
}

View file

@ -0,0 +1,48 @@
import 'dart:async';
import 'package:audio_service/audio_service.dart';
import 'package:just_audio/just_audio.dart';
import '../models/song_model.dart';
import 'audio_manager_contract.dart';
class AudioManagerImpl implements AudioManager {
@override
Future<void> playSong(int index, List<SongModel> songList) async {
final List<MediaItem> queue = songList.map((s) => s.toMediaItem()).toList();
// await AudioService.addQueueItem(queue[index]);
AudioService.playFromMediaId(queue[index].id);
}
}
class AudioPlayerTask extends BackgroundAudioTask {
final _audioPlayer = AudioPlayer();
final _completer = Completer();
final _mediaItems = <String, MediaItem>{};
@override
Future<void> onStart() async {
print('onStart');
await _completer.future;
}
@override
void onStop() {
_audioPlayer.stop();
_completer.complete();
}
@override
void onAddQueueItem(MediaItem mediaItem) {
_mediaItems[mediaItem.id] = mediaItem;
}
@override
Future<void> onPlayFromMediaId(String mediaId) async {
// _audioPlayer.setFilePath(_mediaItems[mediaId].id);
await _audioPlayer.setFilePath(mediaId);
_audioPlayer.play();
}
}

View file

@ -0,0 +1,5 @@
import '../models/song_model.dart';
abstract class AudioManager {
Future<void> playSong(int index, List<SongModel> songList);
}

View file

@ -1,3 +1,4 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter_audio_query/flutter_audio_query.dart';
import 'package:meta/meta.dart';
import 'package:moor/moor.dart';
@ -55,4 +56,13 @@ class SongModel extends Song {
albumArtPath: Value(albumArtPath),
trackNumber: Value(trackNumber),
);
// TODO: test!
MediaItem toMediaItem() => MediaItem(
title: title,
album: album,
artist: artist,
artUri: albumArtPath,
id: path,
);
}

View file

@ -0,0 +1,24 @@
import 'package:dartz/dartz.dart';
import 'package:mosh/system/models/song_model.dart';
import '../../core/error/failures.dart';
import '../../domain/entities/song.dart';
import '../../domain/repositories/audio_repository.dart';
import '../datasources/audio_manager_contract.dart';
class AudioRepositoryImpl implements AudioRepository {
AudioRepositoryImpl(this._audioManager);
final AudioManager _audioManager;
@override
Future<Either<Failure, void>> playSong(int index, List<Song> songList) async {
final List<SongModel> songModelList = songList.map((song) => song as SongModel).toList();
if (0 <= index && index < songList.length) {
await _audioManager.playSong(index, songModelList);
return Right(null);
}
return Left(IndexFailure());
}
}

View file

@ -0,0 +1,51 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:mosh/domain/entities/song.dart';
import 'package:mosh/domain/repositories/audio_repository.dart';
import 'package:mosh/domain/usecases/play_song.dart';
import '../../test_constants.dart';
class MockAudioRepository extends Mock implements AudioRepository {}
void main() {
PlaySong usecase;
MockAudioRepository mockAudioRepository;
setUp(() {
mockAudioRepository = MockAudioRepository();
usecase = PlaySong(mockAudioRepository);
});
const tIndex = 0;
final tSongList = <Song>[
Song(
album: ALBUM_TITLE_3,
artist: ARTIST_3,
title: SONG_TITLE_3,
path: PATH_3,
albumArtPath: ALBUM_ART_PATH_3,
trackNumber: TRACKNUMBER_3,
),
Song(
album: ALBUM_TITLE_4,
artist: ARTIST_4,
title: SONG_TITLE_4,
path: PATH_4,
albumArtPath: ALBUM_ART_PATH_4,
trackNumber: TRACKNUMBER_4,
),
];
test(
'should forward index and song list to repository',
() async {
// act
await usecase(Params(tIndex, tSongList));
// assert
verify(mockAudioRepository.playSong(tIndex, tSongList));
verifyNoMoreInteractions(mockAudioRepository);
},
);
}

View file

@ -0,0 +1,53 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:mosh/system/datasources/audio_manager_contract.dart';
import 'package:mosh/system/models/song_model.dart';
import 'package:mosh/system/repositories/audio_repository_impl.dart';
import '../../test_constants.dart';
class MockAudioManager extends Mock implements AudioManager {}
void main() {
AudioRepositoryImpl repository;
MockAudioManager mockAudioManager;
const int tIndex = 0;
final List<SongModel> tSongList = setupSongList();
setUp(() {
mockAudioManager = MockAudioManager();
repository = AudioRepositoryImpl(mockAudioManager);
});
group('playSong', () {
test(
'should forward index and song list to AudioManager',
() async {
// act
await repository.playSong(tIndex, tSongList);
// assert
verify(mockAudioManager.playSong(tIndex, tSongList));
},
);
});
}
List<SongModel> setupSongList() => [
SongModel(
title: SONG_TITLE_3,
album: ALBUM_TITLE_3,
artist: ARTIST_3,
path: PATH_3,
trackNumber: TRACKNUMBER_3,
albumArtPath: ALBUM_ART_PATH_3,
),
SongModel(
title: SONG_TITLE_4,
album: ALBUM_TITLE_4,
artist: ARTIST_4,
path: PATH_4,
trackNumber: TRACKNUMBER_4,
albumArtPath: ALBUM_ART_PATH_4,
),
];

View file

@ -88,7 +88,8 @@ void main() {
'should get songs from MusicDataSource',
() async {
// arrange
when(mockMusicDataSource.getSongs()).thenAnswer((_) async => tEmptySongList);
when(mockMusicDataSource.getSongs())
.thenAnswer((_) async => tEmptySongList);
// act
final result = await repository.getSongs();
// assert
@ -99,13 +100,18 @@ void main() {
});
group('updateDatabase', () {
setUp(() {});
setUp(() {
when(mockLocalMusicFetcher.getSongs())
.thenAnswer((_) async => tEmptySongList);
when(mockMusicDataSource.songExists(any)).thenAnswer((_) async => false);
});
test(
'should fetch list of albums from LocalMusicFetcher',
() async {
when(mockLocalMusicFetcher.getAlbums())
.thenAnswer((_) async => tAlbumList);
when(mockMusicDataSource.albumExists(any))
.thenAnswer((_) async => false);
// act