diff --git a/README.md b/README.md new file mode 100644 index 0000000..123434c --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# README + +## Things I want to do + +* The app shows currently playing items +* The app allows reordering during the shuffle +* The result of reordering during the shuffle does not affect the song order before the shuffle + +## Current approach + +* Get currently playing items from [MediaControllerCompat.Callback#onQueueChanged](https://developer.android.com/reference/kotlin/android/support/v4/media/session/MediaControllerCompat.Callback#onqueuechanged) +* Customize [TimelineQueueEditor](https://exoplayer.dev/doc/reference/com/google/android/exoplayer2/ext/mediasession/TimelineQueueEditor.html), [ShuffleOrder](https://exoplayer.dev/doc/reference/com/google/android/exoplayer2/source/ShuffleOrder.html) + * doesn't call [Player#moveMediaItem](https://exoplayer.dev/doc/reference/com/google/android/exoplayer2/Player.html#moveMediaItem(int,int)) when reorder during the shuffle + * generate a new `ShuffleOrder` from the sorted result + * call [ExoPlayer#setShuffleOrder](https://exoplayer.dev/doc/reference/com/google/android/exoplayer2/ExoPlayer.html#setShuffleOrder(com.google.android.exoplayer2.source.ShuffleOrder)) to update the sort order + +## Result + +* When reordering several times while shuffling, the song order may not be reflected in MediaSession + * doesn't call [MediaControllerCompat.Callback#onQueueChanged](https://developer.android.com/reference/kotlin/android/support/v4/media/session/MediaControllerCompat.Callback#onqueuechanged) + +## Reproduction Steps + +1. Play music +1. Enable shuffling +1. Reorder playlist items several times +1. Wait to seek to next media item + +## Environment + +* ExoPlayer:2.16.1 +* Test Device: + * Pixel 3(OS 12) + * Pixel 4(OS 11) + * Emulator(OS 10) + +## Music + +Music provided by the [Free Music Archive](http://freemusicarchive.org/). + +* [Irsen's Tale](http://freemusicarchive.org/music/Kai_Engel/Irsens_Tale/) by +[Kai Engel](http://freemusicarchive.org/music/Kai_Engel/) is licensed under [CC BY-NC 3.0](https://creativecommons.org/licenses/by-nc/3.0/). diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..bf2769d --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,52 @@ +plugins { + id 'com.android.application' + id 'kotlin-android' +} + +android { + compileSdk 31 + + defaultConfig { + applicationId "com.example.android.sampleplayer" + minSdk 23 + targetSdk 31 + versionCode 1 + versionName "1.0" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = '1.8' + } +} + +dependencies { + + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2' + + implementation 'androidx.core:core-ktx:1.7.0' + implementation 'androidx.appcompat:appcompat:1.4.0' + implementation 'com.google.android.material:material:1.4.0' + implementation 'androidx.constraintlayout:constraintlayout:2.1.2' + + implementation 'androidx.activity:activity-ktx:1.4.0' + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1' + implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.3.1' + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1' + implementation 'androidx.lifecycle:lifecycle-service:2.3.1' + + implementation 'com.google.android.exoplayer:exoplayer-core:2.16.1' + implementation 'com.google.android.exoplayer:exoplayer-ui:2.16.1' + implementation 'com.google.android.exoplayer:extension-mediasession:2.16.1' + + implementation 'com.jakewharton.timber:timber:5.0.1' +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..d7764e0 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/example/android/sampleplayer/App.kt b/app/src/main/java/com/example/android/sampleplayer/App.kt new file mode 100644 index 0000000..c563813 --- /dev/null +++ b/app/src/main/java/com/example/android/sampleplayer/App.kt @@ -0,0 +1,11 @@ +package com.example.android.sampleplayer + +import android.app.Application +import timber.log.Timber + +class App : Application() { + override fun onCreate() { + super.onCreate() + Timber.plant(Timber.DebugTree()) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/android/sampleplayer/MainActivity.kt b/app/src/main/java/com/example/android/sampleplayer/MainActivity.kt new file mode 100644 index 0000000..5b7d78b --- /dev/null +++ b/app/src/main/java/com/example/android/sampleplayer/MainActivity.kt @@ -0,0 +1,151 @@ +package com.example.android.sampleplayer + +import androidx.appcompat.app.AppCompatActivity +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageButton +import android.widget.TextView +import androidx.activity.viewModels +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.ItemTouchHelper.DOWN +import androidx.recyclerview.widget.ItemTouchHelper.UP +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView + +class MainActivity : AppCompatActivity() { + + private val mainViewModel by viewModels { + MainViewModel.Factory(MusicServiceConnection(application)) + } + + private lateinit var textSongTitle: TextView + private lateinit var buttonPlayOrPause: ImageButton + private lateinit var buttonShuffle: ImageButton + private lateinit var queueList: RecyclerView + + private val queueListAdapter: Adapter = Adapter() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + + textSongTitle = findViewById(R.id.text_song_name) + buttonPlayOrPause = findViewById(R.id.button_play_or_pause) + buttonPlayOrPause.setOnClickListener { + mainViewModel.playOrPause() + } + buttonShuffle = findViewById(R.id.button_shuffle) + buttonShuffle.setOnClickListener { + mainViewModel.toggleShuffleMode() + } + queueList = findViewById(R.id.list_song_queue) + queueList.apply { + layoutManager = LinearLayoutManager(this@MainActivity) + adapter = queueListAdapter + } + ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(UP or DOWN, 0) { + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean { + mainViewModel.move(viewHolder.absoluteAdapterPosition, target.absoluteAdapterPosition) + return true + } + + override fun clearView( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder + ) { + super.clearView(recyclerView, viewHolder) + mainViewModel.finishMove() + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { + // not support + } + + override fun isLongPressDragEnabled() = true + + }).attachToRecyclerView(queueList) + + mainViewModel.isReady.observe(this) { + buttonPlayOrPause.isEnabled = it + } + mainViewModel.playingMediaTitle.observe(this) { + textSongTitle.text = it + } + mainViewModel.isPlaying.observe(this) { + buttonPlayOrPause.setImageResource( + if(it) { + R.drawable.ic_baseline_pause_24 + } else { + R.drawable.ic_baseline_play_arrow_24 + } + ) + } + mainViewModel.shuffleModeEnabled.observe(this) { + buttonShuffle.setImageResource( + if(it) { + R.drawable.ic_baseline_shuffle_on_24 + } else { + R.drawable.ic_baseline_shuffle_24 + } + ) + } + mainViewModel.currentQueue.observe(this) { + queueListAdapter.update(it) + } + + mainViewModel.initialize() + } + + private inner class Adapter : RecyclerView.Adapter() { + + private val songList = mutableListOf() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val view = LayoutInflater.from(parent.context).inflate(R.layout.item_song, parent, false) + return ViewHolder(view) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.textView.text = songList[position].name + } + + override fun getItemCount() = songList.size + + fun update(songList: List) { + val diffCallback = DiffCallback(this.songList, songList) + val diffResult = DiffUtil.calculateDiff(diffCallback) + this.songList.apply { + clear() + addAll(songList) + } + diffResult.dispatchUpdatesTo(this) + } + } + + private inner class ViewHolder(view: View): RecyclerView.ViewHolder(view) { + val textView: TextView = view.findViewById(R.id.text_song_name) + } + + private inner class DiffCallback( + private val oldSongList: List, + private val newSongList: List + ) : DiffUtil.Callback() { + override fun getOldListSize() = oldSongList.size + + override fun getNewListSize() = newSongList.size + + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldSongList[oldItemPosition].id == newSongList[newItemPosition].id + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldSongList[oldItemPosition] == newSongList[newItemPosition] + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/android/sampleplayer/MainViewModel.kt b/app/src/main/java/com/example/android/sampleplayer/MainViewModel.kt new file mode 100644 index 0000000..b28f41f --- /dev/null +++ b/app/src/main/java/com/example/android/sampleplayer/MainViewModel.kt @@ -0,0 +1,102 @@ +package com.example.android.sampleplayer + +import android.support.v4.media.MediaBrowserCompat +import android.support.v4.media.MediaMetadataCompat +import androidx.lifecycle.* +import com.example.android.sampleplayer.extension.isPlaying +import com.example.android.sampleplayer.extension.toSong +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class MainViewModel( + private val musicServiceConnection: MusicServiceConnection +) : ViewModel() { + + val isReady: LiveData = musicServiceConnection.isConnected.asLiveData(viewModelScope.coroutineContext) + val isPlaying: LiveData = musicServiceConnection.playbackState.map { + it.isPlaying + }.asLiveData(viewModelScope.coroutineContext) + val shuffleModeEnabled: LiveData = musicServiceConnection.shuffleModeEnabled.asLiveData(viewModelScope.coroutineContext) + + val playingMediaTitle: LiveData = musicServiceConnection.playingMedia.map { + it.getString(MediaMetadataCompat.METADATA_KEY_TITLE) + }.asLiveData(viewModelScope.coroutineContext) + private val _currentQueue: MutableStateFlow> = MutableStateFlow(emptyList()) + val currentQueue: LiveData> = _currentQueue.asLiveData(viewModelScope.coroutineContext) + + private val subscriptionCallback = object : MediaBrowserCompat.SubscriptionCallback() { + } + + private var _moveFrom = ITEM_INDEX_NOT_MOVING + private var _moveTo = ITEM_INDEX_NOT_MOVING + + init { + viewModelScope.launch { + musicServiceConnection.currentQueue.collect { it -> + _currentQueue.value = it.map { queueItem -> + queueItem.toSong() + } + } + } + } + + override fun onCleared() { + super.onCleared() + musicServiceConnection.unsubscribe(Playlist.DUMMY_PLAYLIST_ID ,subscriptionCallback) + } + + fun initialize() { + musicServiceConnection.subscribe(Playlist.DUMMY_PLAYLIST_ID, subscriptionCallback) + } + + fun playOrPause() { + viewModelScope.launch { + musicServiceConnection.playOrPause() + } + } + + fun toggleShuffleMode() { + viewModelScope.launch { + musicServiceConnection.toggleShuffleMode() + } + } + + fun move(from: Int, to: Int) { + val tmp = currentQueue.value + if(tmp != null) { + _currentQueue.value = _currentQueue.value.toMutableList().apply { + val backup = this[from] + this[from] = this[to] + this[to] = backup + } + if (_moveFrom == ITEM_INDEX_NOT_MOVING) { + _moveFrom = from + } + _moveTo = to + } + } + + fun finishMove() { + viewModelScope.launch { + musicServiceConnection.move(_moveFrom, _moveTo) + _moveFrom = ITEM_INDEX_NOT_MOVING + _moveTo = ITEM_INDEX_NOT_MOVING + } + } + + class Factory( + private val musicServiceConnection: MusicServiceConnection + ) : ViewModelProvider.NewInstanceFactory() { + + @Suppress("unchecked_cast") + override fun create(modelClass: Class): T { + return MainViewModel(musicServiceConnection) as T + } + } + + companion object { + private const val ITEM_INDEX_NOT_MOVING = -1 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/android/sampleplayer/MusicService.kt b/app/src/main/java/com/example/android/sampleplayer/MusicService.kt new file mode 100644 index 0000000..90ec02a --- /dev/null +++ b/app/src/main/java/com/example/android/sampleplayer/MusicService.kt @@ -0,0 +1,222 @@ +package com.example.android.sampleplayer + +import CustomShuffleOrder +import android.app.PendingIntent +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.os.IBinder +import android.os.ResultReceiver +import android.support.v4.media.MediaBrowserCompat +import android.support.v4.media.MediaDescriptionCompat +import android.support.v4.media.session.MediaSessionCompat +import android.support.v4.media.session.PlaybackStateCompat +import androidx.core.os.bundleOf +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ServiceLifecycleDispatcher +import androidx.lifecycle.lifecycleScope +import androidx.media.MediaBrowserServiceCompat +import com.example.android.sampleplayer.custom.CustomTimelineQueueEditor +import com.example.android.sampleplayer.extension.toMediaBrowserCompatMediaItem +import com.example.android.sampleplayer.extension.toMediaDescriptionCompat +import com.google.android.exoplayer2.* +import com.google.android.exoplayer2.analytics.AnalyticsCollector +import com.google.android.exoplayer2.audio.AudioAttributes +import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector +import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator +import com.google.android.exoplayer2.source.DefaultMediaSourceFactory +import com.google.android.exoplayer2.source.MediaSourceFactory +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector +import com.google.android.exoplayer2.upstream.DefaultBandwidthMeter +import com.google.android.exoplayer2.util.Clock +import kotlinx.coroutines.launch + +class MusicService : MediaBrowserServiceCompat(), LifecycleOwner { + + private val dispatcher = ServiceLifecycleDispatcher(this) + + private lateinit var mediaSession: MediaSessionCompat + private lateinit var mediaSessionConnector: MediaSessionConnector + + private lateinit var exoPlayer: ExoPlayer + private lateinit var defaultTrackSelector: DefaultTrackSelector + private lateinit var mediaSourceFactory: MediaSourceFactory + + private val musicPlayerListener = MusicPlayerListener() + + override fun onCreate() { + super.onCreate() + dispatcher.onServicePreSuperOnCreate() + + defaultTrackSelector = DefaultTrackSelector(this) + mediaSourceFactory = DefaultMediaSourceFactory(this) + exoPlayer = ExoPlayer.Builder( + this, + DefaultRenderersFactory(this), + mediaSourceFactory, + defaultTrackSelector, + DefaultLoadControl(), + DefaultBandwidthMeter.getSingletonInstance(this), + AnalyticsCollector(Clock.DEFAULT) + ).setAudioAttributes( + AudioAttributes.Builder().setContentType(C.CONTENT_TYPE_MUSIC).build(), + true + ).setHandleAudioBecomingNoisy(true) + .build() + exoPlayer.addListener(musicPlayerListener) + + val activityIntent = packageManager?.getLaunchIntentForPackage(packageName)?.let { + PendingIntent.getActivity(this, 0, it, PendingIntent.FLAG_IMMUTABLE) + } + mediaSession = MediaSessionCompat(this, TAG).apply { + setSessionActivity(activityIntent) + isActive = true + } + sessionToken = mediaSession.sessionToken + + mediaSessionConnector = MediaSessionConnector(mediaSession).apply { + setPlaybackPreparer(MusicPlaybackPrepare()) + setQueueNavigator(object : TimelineQueueNavigator(mediaSession, MAX_QUEUE_SIZE) { + override fun getMediaDescription( + player: Player, + windowIndex: Int + ): MediaDescriptionCompat { + return player.getMediaItemAt(windowIndex).toMediaDescriptionCompat() + } + }) + // !!!customize for support reordering during the shuffle.!!! + setQueueEditor( + CustomTimelineQueueEditor( + mediaSession.controller, + object : CustomTimelineQueueEditor.QueueDataAdapter { + override fun add(position: Int, description: MediaDescriptionCompat) { + // not support + } + + override fun remove(position: Int) { + // not support + } + + override fun move( + from: Int, + to: Int, + handleOnPlayer: Boolean, + currentQueue: List + ) { + if(handleOnPlayer.not()) { + exoPlayer.setShuffleOrder( + CustomShuffleOrder.cloneAndMove( + currentQueue.map { it.queueId.toInt() }.toList().toIntArray(), + from, + to + ) + ) + } + } + }, + object : CustomTimelineQueueEditor.MediaDescriptionConverter { + override fun convert(description: MediaDescriptionCompat?): MediaItem? { + return description?.toMediaBrowserCompatMediaItem() + } + } + ) + ) + // !!!customize for support reordering during the shuffle.!!! + setPlayer(exoPlayer) + } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + dispatcher.onServicePreSuperOnStart() + return super.onStartCommand(intent, flags, startId) + } + + override fun onDestroy() { + mediaSession.run { + isActive = false + release() + } + exoPlayer.removeListener(musicPlayerListener) + exoPlayer.release() + dispatcher.onServicePreSuperOnDestroy() + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? { + dispatcher.onServicePreSuperOnBind() + return super.onBind(intent) + } + + override fun getLifecycle(): Lifecycle = dispatcher.lifecycle + + override fun onGetRoot( + clientPackageName: String, + clientUid: Int, + rootHints: Bundle? + ): BrowserRoot { + return BrowserRoot( + ROOT_ID, + bundleOf() + ) + } + + override fun onLoadChildren( + parentId: String, + result: Result> + ) { + lifecycleScope.launch { + val playlist = Playlist.createDummyPlaylist(packageName) + result.sendResult(playlist.songList.map { it.toMediaBrowserCompatMediaItem() }) + } + } + + private inner class MusicPlayerListener : Player.Listener { + } + + private inner class MusicPlaybackPrepare : MediaSessionConnector.PlaybackPreparer { + override fun onCommand( + player: Player, + command: String, + extras: Bundle?, + cb: ResultReceiver? + ) = false + + override fun getSupportedPrepareActions(): Long { + return PlaybackStateCompat.ACTION_PREPARE_FROM_MEDIA_ID or + PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID + } + + override fun onPrepare(playWhenReady: Boolean) { + // noop + } + + override fun onPrepareFromMediaId( + mediaId: String, + playWhenReady: Boolean, + extras: Bundle? + ) { + val playlist = Playlist.createDummyPlaylist(packageName) + + exoPlayer.setShuffleOrder(CustomShuffleOrder(0)) + exoPlayer.setMediaSources(playlist.songList.map { it.toMediaSource(mediaSourceFactory) }) + exoPlayer.prepare() + exoPlayer.seekTo(0, C.TIME_UNSET) + exoPlayer.playWhenReady = playWhenReady + } + + override fun onPrepareFromSearch(query: String, playWhenReady: Boolean, extras: Bundle?) { + // noop + } + + override fun onPrepareFromUri(uri: Uri, playWhenReady: Boolean, extras: Bundle?) { + // noop + } + } + + companion object { + private const val TAG = "MusicService" + private const val ROOT_ID = "RootID" + private const val MAX_QUEUE_SIZE = 10 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/android/sampleplayer/MusicServiceConnection.kt b/app/src/main/java/com/example/android/sampleplayer/MusicServiceConnection.kt new file mode 100644 index 0000000..b2ed17a --- /dev/null +++ b/app/src/main/java/com/example/android/sampleplayer/MusicServiceConnection.kt @@ -0,0 +1,159 @@ +package com.example.android.sampleplayer + +import android.content.ComponentName +import android.content.Context +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.os.ResultReceiver +import android.support.v4.media.MediaBrowserCompat +import android.support.v4.media.MediaMetadataCompat +import android.support.v4.media.session.MediaControllerCompat +import android.support.v4.media.session.MediaSessionCompat +import android.support.v4.media.session.PlaybackStateCompat +import androidx.core.os.bundleOf +import com.example.android.sampleplayer.extension.EMPTY_PLAYBACK_STATE +import com.example.android.sampleplayer.extension.isPlaying +import com.example.android.sampleplayer.extension.isPrepared +import com.google.android.exoplayer2.ext.mediasession.TimelineQueueEditor +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import timber.log.Timber + +class MusicServiceConnection( + context: Context +) { + private val mediaBrowserConnectionCallback = MediaBrowserConnectionCallback(context) + private val mediaBrowser = MediaBrowserCompat( + context, + ComponentName( + context, + MusicService::class.java + ), + mediaBrowserConnectionCallback, + null + ).apply { connect() } + private lateinit var mediaController: MediaControllerCompat + private val transportControls: MediaControllerCompat.TransportControls + get() = mediaController.transportControls + + private val _isConnected = MutableStateFlow(false) + val isConnected: Flow = _isConnected + + private val _playbackState = MutableStateFlow(EMPTY_PLAYBACK_STATE) + val playbackState: Flow = _playbackState + + private val _playingMedia: MutableStateFlow = MutableStateFlow(NOTHING_PLAYING) + val playingMedia: Flow = _playingMedia + + private val _currentQueue: MutableStateFlow> = MutableStateFlow(emptyList()) + val currentQueue: Flow> = _currentQueue + + private val _shuffleModeEnabled = MutableStateFlow(false) + val shuffleModeEnabled: Flow = _shuffleModeEnabled + + fun subscribe(parentId: String, callback: MediaBrowserCompat.SubscriptionCallback) { + mediaBrowser.subscribe(parentId, callback) + } + + fun unsubscribe(parentId: String, callback: MediaBrowserCompat.SubscriptionCallback) { + mediaBrowser.unsubscribe(parentId, callback) + } + + suspend fun playOrPause() { + val playbackState = _playbackState.first() + if(playbackState.isPrepared) { + if(playbackState.isPlaying) { + transportControls.pause() + } else { + transportControls.play() + } + } else { + transportControls.playFromMediaId( + Playlist.DUMMY_PLAYLIST_ID, + bundleOf() + ) + } + } + + suspend fun toggleShuffleMode() { + val shuffleEnabled = _shuffleModeEnabled.first() + transportControls.setShuffleMode( + if(shuffleEnabled) { + PlaybackStateCompat.SHUFFLE_MODE_NONE + } else { + PlaybackStateCompat.SHUFFLE_MODE_ALL + } + ) + } + + suspend fun move(from: Int, to: Int) { + if (_isConnected.first()) { + val params = bundleOf(TimelineQueueEditor.EXTRA_FROM_INDEX to from, TimelineQueueEditor.EXTRA_TO_INDEX to to) + mediaController.sendCommand( + TimelineQueueEditor.COMMAND_MOVE_QUEUE_ITEM, params, + object : ResultReceiver(Handler(Looper.getMainLooper())) { + override fun onReceiveResult(resultCode: Int, resultData: Bundle?) { + super.onReceiveResult(resultCode, resultData) + } + } + ) + } + } + + private inner class MediaBrowserConnectionCallback( + private val context: Context + ) : MediaBrowserCompat.ConnectionCallback() { + override fun onConnected() { + mediaController = MediaControllerCompat(context, mediaBrowser.sessionToken).apply { + registerCallback(MediaControllerCallback()) + } + _isConnected.value = true + } + + override fun onConnectionSuspended() { + _isConnected.value = false + } + + override fun onConnectionFailed() { + _isConnected.value = false + } + } + + private inner class MediaControllerCallback : MediaControllerCompat.Callback() { + override fun onPlaybackStateChanged(state: PlaybackStateCompat?) { + state?.let { + _playbackState.value = it + } + } + + override fun onMetadataChanged(metadata: MediaMetadataCompat?) { + metadata?.let { + _playingMedia.value = metadata + } + } + + override fun onQueueChanged(queue: MutableList?) { + Timber.d("onQueueChanged : ${queue?.map { it.description.mediaId }}") + queue?.let { + _currentQueue.value = queue + } + } + + override fun onShuffleModeChanged(shuffleMode: Int) { + _shuffleModeEnabled.value = shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_ALL + } + + override fun onSessionDestroyed() { + mediaBrowserConnectionCallback.onConnectionSuspended() + } + } + + companion object { + val NOTHING_PLAYING: MediaMetadataCompat = MediaMetadataCompat.Builder() + .putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, "") + .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, 0) + .build() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/android/sampleplayer/Playlist.kt b/app/src/main/java/com/example/android/sampleplayer/Playlist.kt new file mode 100644 index 0000000..2565a4a --- /dev/null +++ b/app/src/main/java/com/example/android/sampleplayer/Playlist.kt @@ -0,0 +1,40 @@ +package com.example.android.sampleplayer + +import android.content.ContentResolver + +class Playlist( + val id: String, + val title: String, + val songList: List +) { + companion object { + + const val DUMMY_PLAYLIST_ID = "dummyPlaylist" + + fun createDummyPlaylist(packageName: String): Playlist { + val songTitleList = listOf( + "Intro (.udonthear)", + "Leaving", + "Irsen's Tale", + "Moonlight Reprise", + "Nothing Lasts Forever", + "The Moments of Our Mornings", + "Laceration", + "Memories", + "Outro" + ) + return Playlist( + DUMMY_PLAYLIST_ID, + "Irsen's Tale", + songTitleList.mapIndexed { index, title -> + val mediaId = "media$index" + Song( + mediaId, + title, + "${ContentResolver.SCHEME_ANDROID_RESOURCE}://$packageName/raw/$mediaId" + ) + } + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/android/sampleplayer/Song.kt b/app/src/main/java/com/example/android/sampleplayer/Song.kt new file mode 100644 index 0000000..d2e404a --- /dev/null +++ b/app/src/main/java/com/example/android/sampleplayer/Song.kt @@ -0,0 +1,39 @@ +package com.example.android.sampleplayer + +import android.support.v4.media.MediaBrowserCompat +import android.support.v4.media.MediaDescriptionCompat +import androidx.core.net.toUri +import com.google.android.exoplayer2.MediaItem +import com.google.android.exoplayer2.MediaMetadata +import com.google.android.exoplayer2.source.MediaSource +import com.google.android.exoplayer2.source.MediaSourceFactory + +data class Song( + val id: String, + val name: String, + val contentUrl: String +) { + fun toMediaBrowserCompatMediaItem(): MediaBrowserCompat.MediaItem = + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setMediaId(id) + .setTitle(name) + .setMediaUri(contentUrl.toUri()) + .build(), + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) + + fun toMediaSource(mediaSourceFactory: MediaSourceFactory) : MediaSource = + mediaSourceFactory.createMediaSource(toExoPlayerMediaItem()) + + private fun toExoPlayerMediaItem(): MediaItem { + return MediaItem.Builder() + .setMediaId(id) + .setUri(contentUrl.toUri()) + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(name) + .build() + ).build() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/android/sampleplayer/custom/CustomShuffleOrder.kt b/app/src/main/java/com/example/android/sampleplayer/custom/CustomShuffleOrder.kt new file mode 100644 index 0000000..5926142 --- /dev/null +++ b/app/src/main/java/com/example/android/sampleplayer/custom/CustomShuffleOrder.kt @@ -0,0 +1,120 @@ +import com.google.android.exoplayer2.C +import com.google.android.exoplayer2.source.ShuffleOrder +import java.util.* +import kotlin.random.Random + +class CustomShuffleOrder private constructor( + private val shuffled: IntArray, + private val random: Random +) : ShuffleOrder { + private val indexInShuffled: IntArray = IntArray(shuffled.size) + + constructor(length: Int) : this(length, Random.Default) + constructor(length: Int, randomSeed: Long) : this(length, Random(randomSeed)) + constructor(shuffledIndices: IntArray, randomSeed: Long) : this( + shuffledIndices.copyOf(shuffledIndices.size), Random(randomSeed) + ) + private constructor(length: Int, random: Random) : this( + createShuffledList(length, random), + random + ) + + override fun getLength(): Int { + return shuffled.size + } + + override fun getNextIndex(index: Int): Int { + var shuffledIndex = indexInShuffled[index] + return if (++shuffledIndex < shuffled.size) shuffled[shuffledIndex] else C.INDEX_UNSET + } + + override fun getPreviousIndex(index: Int): Int { + var shuffledIndex = indexInShuffled[index] + return if (--shuffledIndex >= 0) shuffled[shuffledIndex] else C.INDEX_UNSET + } + + override fun getLastIndex(): Int { + return if (shuffled.isNotEmpty()) shuffled[shuffled.size - 1] else C.INDEX_UNSET + } + + override fun getFirstIndex(): Int { + return if (shuffled.isNotEmpty()) shuffled[0] else C.INDEX_UNSET + } + + override fun cloneAndInsert(insertionIndex: Int, insertionCount: Int): ShuffleOrder { + val insertionPoints = IntArray(insertionCount) + val insertionValues = IntArray(insertionCount) + for (i in 0 until insertionCount) { + insertionPoints[i] = random.nextInt(shuffled.size + 1) + val swapIndex = random.nextInt(i + 1) + insertionValues[i] = insertionValues[swapIndex] + insertionValues[swapIndex] = i + insertionIndex + } + Arrays.sort(insertionPoints) + val newShuffled = IntArray(shuffled.size + insertionCount) + var indexInOldShuffled = 0 + var indexInInsertionList = 0 + for (i in 0 until shuffled.size + insertionCount) { + if (indexInInsertionList < insertionCount + && indexInOldShuffled == insertionPoints[indexInInsertionList] + ) { + newShuffled[i] = insertionValues[indexInInsertionList++] + } else { + newShuffled[i] = shuffled[indexInOldShuffled++] + if (newShuffled[i] >= insertionIndex) { + newShuffled[i] += insertionCount + } + } + } + return CustomShuffleOrder(newShuffled, Random(random.nextLong())) + } + + override fun cloneAndRemove(indexFrom: Int, indexToExclusive: Int): ShuffleOrder { + val numberOfElementsToRemove = indexToExclusive - indexFrom + val newShuffled = IntArray(shuffled.size - numberOfElementsToRemove) + var foundElementsCount = 0 + for (i in shuffled.indices) { + if (shuffled[i] in indexFrom until indexToExclusive) { + foundElementsCount++ + } else { + newShuffled[i - foundElementsCount] = + if (shuffled[i] >= indexFrom) shuffled[i] - numberOfElementsToRemove else shuffled[i] + } + } + return CustomShuffleOrder(newShuffled, Random(random.nextLong())) + } + + override fun cloneAndClear(): ShuffleOrder { + return CustomShuffleOrder(0, Random(random.nextLong())) + } + + companion object { + private fun createShuffledList(length: Int, random: Random): IntArray { + val shuffled = IntArray(length) + for (i in 0 until length) { + val swapIndex = random.nextInt(i + 1) + shuffled[i] = shuffled[swapIndex] + shuffled[swapIndex] = i + } + return shuffled + } + + // !!!customize for support reordering during the shuffle.!!! + fun cloneAndMove(shuffled: IntArray, from: Int, to: Int): ShuffleOrder { + val newShuffled = shuffled.toMutableList() + newShuffled.removeAt(from) + newShuffled.add(to, shuffled[from]) + return CustomShuffleOrder( + newShuffled.toIntArray(), + Random.Default + ) + } + // !!!customize for support reordering during the shuffle.!!! + } + + init { + for (i in shuffled.indices) { + indexInShuffled[shuffled[i]] = i + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/android/sampleplayer/custom/CustomTimelineQueueEditor.kt b/app/src/main/java/com/example/android/sampleplayer/custom/CustomTimelineQueueEditor.kt new file mode 100644 index 0000000..9a6ec48 --- /dev/null +++ b/app/src/main/java/com/example/android/sampleplayer/custom/CustomTimelineQueueEditor.kt @@ -0,0 +1,94 @@ +package com.example.android.sampleplayer.custom + +import android.os.Bundle +import android.os.ResultReceiver +import android.support.v4.media.MediaDescriptionCompat +import android.support.v4.media.session.MediaControllerCompat +import android.support.v4.media.session.MediaSessionCompat +import com.google.android.exoplayer2.C +import com.google.android.exoplayer2.MediaItem +import com.google.android.exoplayer2.Player +import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector +import com.google.android.exoplayer2.ext.mediasession.TimelineQueueEditor.* +import com.google.android.exoplayer2.util.Util + +class CustomTimelineQueueEditor @JvmOverloads constructor( + private val mediaController: MediaControllerCompat, + private val queueDataAdapter: QueueDataAdapter, + private val mediaDescriptionConverter: MediaDescriptionConverter, + private val equalityChecker: MediaDescriptionEqualityChecker = MediaIdEqualityChecker() +) : MediaSessionConnector.QueueEditor, MediaSessionConnector.CommandReceiver { + + interface MediaDescriptionConverter { + fun convert(description: MediaDescriptionCompat?): MediaItem? + } + + interface QueueDataAdapter { + fun add(position: Int, description: MediaDescriptionCompat) + + fun remove(position: Int) + + fun move( + from: Int, + to: Int, + handleOnPlayer: Boolean, + currentQueue: List + ) + } + + interface MediaDescriptionEqualityChecker { + fun equals(d1: MediaDescriptionCompat, d2: MediaDescriptionCompat): Boolean + } + + class MediaIdEqualityChecker : MediaDescriptionEqualityChecker { + override fun equals(d1: MediaDescriptionCompat, d2: MediaDescriptionCompat): Boolean { + return Util.areEqual(d1.mediaId, d2.mediaId) + } + } + + override fun onAddQueueItem(player: Player, description: MediaDescriptionCompat) { + onAddQueueItem(player, description, player.currentTimeline.windowCount) + } + + override fun onAddQueueItem(player: Player, description: MediaDescriptionCompat, index: Int) { + val mediaItem = mediaDescriptionConverter.convert(description) + if (mediaItem != null) { + queueDataAdapter.add(index, description) + player.addMediaItem(index, mediaItem) + } + } + + override fun onRemoveQueueItem(player: Player, description: MediaDescriptionCompat) { + val queue = mediaController.queue + for (i in queue.indices) { + if (equalityChecker.equals(queue[i].description, description)) { + queueDataAdapter.remove(i) + player.removeMediaItem(i) + return + } + } + } + + override fun onCommand( + player: Player, + command: String, + extras: Bundle?, + cb: ResultReceiver? + ): Boolean { + if (COMMAND_MOVE_QUEUE_ITEM != command || extras == null) { + return false + } + val from = extras.getInt(EXTRA_FROM_INDEX, C.INDEX_UNSET) + val to = extras.getInt(EXTRA_TO_INDEX, C.INDEX_UNSET) + if (from != C.INDEX_UNSET && to != C.INDEX_UNSET) { + // !!!customize for support reordering during the shuffle.!!! + val handleOnPlayer = player.shuffleModeEnabled.not() + queueDataAdapter.move(from, to, handleOnPlayer, mediaController.queue) + if (handleOnPlayer) { + player.moveMediaItem(from, to) + } + // !!!customize for support reordering during the shuffle.!!! + } + return true + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/android/sampleplayer/extension/MediaDescriptionCompatExtension.kt b/app/src/main/java/com/example/android/sampleplayer/extension/MediaDescriptionCompatExtension.kt new file mode 100644 index 0000000..5c8bb90 --- /dev/null +++ b/app/src/main/java/com/example/android/sampleplayer/extension/MediaDescriptionCompatExtension.kt @@ -0,0 +1,14 @@ +package com.example.android.sampleplayer.extension + +import android.support.v4.media.MediaDescriptionCompat +import com.google.android.exoplayer2.MediaItem +import com.google.android.exoplayer2.MediaMetadata + +fun MediaDescriptionCompat.toMediaBrowserCompatMediaItem() = MediaItem.Builder() + .setMediaId("$mediaId") + .setUri(mediaUri) + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(title) + .build() + ).build() \ No newline at end of file diff --git a/app/src/main/java/com/example/android/sampleplayer/extension/MediaItemExtension.kt b/app/src/main/java/com/example/android/sampleplayer/extension/MediaItemExtension.kt new file mode 100644 index 0000000..d0bae42 --- /dev/null +++ b/app/src/main/java/com/example/android/sampleplayer/extension/MediaItemExtension.kt @@ -0,0 +1,10 @@ +package com.example.android.sampleplayer.extension + +import android.support.v4.media.MediaDescriptionCompat +import com.google.android.exoplayer2.MediaItem + +fun MediaItem.toMediaDescriptionCompat(): MediaDescriptionCompat = MediaDescriptionCompat.Builder() + .setMediaId(mediaId) + .setMediaUri(playbackProperties?.uri) + .setTitle(mediaMetadata.title) + .build() \ No newline at end of file diff --git a/app/src/main/java/com/example/android/sampleplayer/extension/PlaybackStateCompatExtension.kt b/app/src/main/java/com/example/android/sampleplayer/extension/PlaybackStateCompatExtension.kt new file mode 100644 index 0000000..d4dad3c --- /dev/null +++ b/app/src/main/java/com/example/android/sampleplayer/extension/PlaybackStateCompatExtension.kt @@ -0,0 +1,17 @@ +package com.example.android.sampleplayer.extension + +import android.support.v4.media.session.PlaybackStateCompat + +val EMPTY_PLAYBACK_STATE: PlaybackStateCompat = PlaybackStateCompat.Builder() + .setState(PlaybackStateCompat.STATE_NONE, 0, 0f) + .build() + +inline val PlaybackStateCompat.isPrepared + get() = (state == PlaybackStateCompat.STATE_BUFFERING) || + (state == PlaybackStateCompat.STATE_PLAYING) || + (state == PlaybackStateCompat.STATE_PAUSED) || + (state == PlaybackStateCompat.STATE_STOPPED) + +inline val PlaybackStateCompat.isPlaying + get() = (state == PlaybackStateCompat.STATE_BUFFERING) || + (state == PlaybackStateCompat.STATE_PLAYING) \ No newline at end of file diff --git a/app/src/main/java/com/example/android/sampleplayer/extension/QueueItemExtension.kt b/app/src/main/java/com/example/android/sampleplayer/extension/QueueItemExtension.kt new file mode 100644 index 0000000..a600b01 --- /dev/null +++ b/app/src/main/java/com/example/android/sampleplayer/extension/QueueItemExtension.kt @@ -0,0 +1,10 @@ +package com.example.android.sampleplayer.extension + +import android.support.v4.media.session.MediaSessionCompat +import com.example.android.sampleplayer.Song + +fun MediaSessionCompat.QueueItem.toSong() = Song( + description.mediaId!!, + description.title!!.toString(), + description.mediaUri!!.toString() +) \ No newline at end of file diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_baseline_pause_24.xml b/app/src/main/res/drawable/ic_baseline_pause_24.xml new file mode 100644 index 0000000..13d6d2e --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_pause_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_play_arrow_24.xml b/app/src/main/res/drawable/ic_baseline_play_arrow_24.xml new file mode 100644 index 0000000..13c137a --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_play_arrow_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_reorder_24.xml b/app/src/main/res/drawable/ic_baseline_reorder_24.xml new file mode 100644 index 0000000..57af84b --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_reorder_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_shuffle_24.xml b/app/src/main/res/drawable/ic_baseline_shuffle_24.xml new file mode 100644 index 0000000..2469a90 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_shuffle_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_shuffle_on_24.xml b/app/src/main/res/drawable/ic_baseline_shuffle_on_24.xml new file mode 100644 index 0000000..6cd19c6 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_shuffle_on_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..3270748 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_song.xml b/app/src/main/res/layout/item_song.xml new file mode 100644 index 0000000..39ea14c --- /dev/null +++ b/app/src/main/res/layout/item_song.xml @@ -0,0 +1,23 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..eca70cf --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..eca70cf --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/raw/media0.mp3 b/app/src/main/res/raw/media0.mp3 new file mode 100644 index 0000000..9ce9426 Binary files /dev/null and b/app/src/main/res/raw/media0.mp3 differ diff --git a/app/src/main/res/raw/media1.mp3 b/app/src/main/res/raw/media1.mp3 new file mode 100644 index 0000000..9ff5749 Binary files /dev/null and b/app/src/main/res/raw/media1.mp3 differ diff --git a/app/src/main/res/raw/media2.mp3 b/app/src/main/res/raw/media2.mp3 new file mode 100644 index 0000000..b6ded64 Binary files /dev/null and b/app/src/main/res/raw/media2.mp3 differ diff --git a/app/src/main/res/raw/media3.mp3 b/app/src/main/res/raw/media3.mp3 new file mode 100644 index 0000000..f85b64a Binary files /dev/null and b/app/src/main/res/raw/media3.mp3 differ diff --git a/app/src/main/res/raw/media4.mp3 b/app/src/main/res/raw/media4.mp3 new file mode 100644 index 0000000..ccc9a82 Binary files /dev/null and b/app/src/main/res/raw/media4.mp3 differ diff --git a/app/src/main/res/raw/media5.mp3 b/app/src/main/res/raw/media5.mp3 new file mode 100644 index 0000000..55325aa Binary files /dev/null and b/app/src/main/res/raw/media5.mp3 differ diff --git a/app/src/main/res/raw/media6.mp3 b/app/src/main/res/raw/media6.mp3 new file mode 100644 index 0000000..3358fa4 Binary files /dev/null and b/app/src/main/res/raw/media6.mp3 differ diff --git a/app/src/main/res/raw/media7.mp3 b/app/src/main/res/raw/media7.mp3 new file mode 100644 index 0000000..669004f Binary files /dev/null and b/app/src/main/res/raw/media7.mp3 differ diff --git a/app/src/main/res/raw/media8.mp3 b/app/src/main/res/raw/media8.mp3 new file mode 100644 index 0000000..aa55678 Binary files /dev/null and b/app/src/main/res/raw/media8.mp3 differ diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..a320863 --- /dev/null +++ b/app/src/main/res/values-night/themes.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..66bef96 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + SamplePlayer + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..a6c7d7c --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..9988a75 --- /dev/null +++ b/build.gradle @@ -0,0 +1,18 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath "com.android.tools.build:gradle:7.0.4" + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.10" + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..98bed16 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,21 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app"s APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Automatically convert third-party libraries to use AndroidX +android.enableJetifier=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e708b1c Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..fcd914b --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Wed Dec 22 14:27:55 JST 2021 +distributionBase=GRADLE_USER_HOME +distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-bin.zip +distributionPath=wrapper/dists +zipStorePath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..4f906e0 --- /dev/null +++ b/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..ac1b06f --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..ed95d70 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,10 @@ +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + jcenter() // Warning: this repository is going to shut down soon + } +} +rootProject.name = "SamplePlayer" +include ':app'