diff --git a/.gitignore b/.gitignore index 79859d12a..09b993d06 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ +*.iml .gradle /local.properties /.idea .DS_Store /build -*.iml +/captures +.externalNativeBuild diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 000000000..796b96d1c --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 000000000..355f7fb45 --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,55 @@ +/* + * Copyright 2017 Google Inc. All rights reserved. + * + * 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 + * + * http://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. + */ + +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlin-android-extensions' + +android { + compileSdkVersion 26 + defaultConfig { + applicationId "com.example.android.uamp.next" + minSdkVersion 19 + targetSdkVersion 26 + versionCode 1 + versionName "1.0" + testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } +} + +dependencies { + implementation project(':media') + + implementation "org.jetbrains.kotlin:kotlin-stdlib-jre7:$kotlin_version" + + implementation 'com.android.support:appcompat-v7:26.1.0' + implementation 'com.android.support.constraint:constraint-layout:1.0.2' + implementation "android.arch.lifecycle:extensions:1.0.0" + + implementation 'com.android.support:support-v4:26.1.0' + implementation 'com.android.support:recyclerview-v7:26.1.0' + + testImplementation 'junit:junit:4.12' + + androidTestImplementation 'com.android.support.test:runner:1.0.1' + androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.1' +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 000000000..f1b424510 --- /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 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..dc9977947 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/example/android/uamp/MainActivity.kt b/app/src/main/java/com/example/android/uamp/MainActivity.kt new file mode 100644 index 000000000..cd616b3ca --- /dev/null +++ b/app/src/main/java/com/example/android/uamp/MainActivity.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2017 Google Inc. All rights reserved. + * + * 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 + * + * http://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. + */ + +package com.example.android.uamp + +import android.arch.lifecycle.ViewModelProviders +import android.os.Bundle +import android.support.v7.app.AppCompatActivity + +class MainActivity : AppCompatActivity(), ConnectionCallback { + + private lateinit var mediaBrowserConnection: MediaBrowserViewModel + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + + mediaBrowserConnection = ViewModelProviders.of(this).get(MediaBrowserViewModel::class.java) + } + + override fun onStart() { + super.onStart() + mediaBrowserConnection.registerCallback(this) + } + + override fun onStop() { + super.onStop() + mediaBrowserConnection.unregisterCallback(this) + } + + override fun onConnected() { + super.onConnected() + + navigateToBrowser(mediaBrowserConnection.getRoot()) + } + + private fun navigateToBrowser(mediaId: String) { + var fragment: MediaItemFragment? = getBrowseFragment(mediaId) + + if (fragment == null) { + fragment = MediaItemFragment.newInstance(mediaId) + val transaction = supportFragmentManager.beginTransaction() + transaction.replace(R.id.browse_fragment, fragment, mediaId) + + // If this is not the top level media (root), we add it to the fragment back stack, + // so that actionbar toggle and Back will work appropriately: + if (mediaId != mediaBrowserConnection.getRoot()) { + transaction.addToBackStack(null) + } + transaction.commit() + } + } + + private fun getBrowseFragment(mediaId: String): MediaItemFragment? { + return fragmentManager.findFragmentByTag(mediaId) as MediaItemFragment? + } +} diff --git a/app/src/main/java/com/example/android/uamp/MediaBrowserViewModel.kt b/app/src/main/java/com/example/android/uamp/MediaBrowserViewModel.kt new file mode 100644 index 000000000..f699eca7f --- /dev/null +++ b/app/src/main/java/com/example/android/uamp/MediaBrowserViewModel.kt @@ -0,0 +1,128 @@ +/* + * Copyright 2017 Google Inc. All rights reserved. + * + * 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 + * + * http://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. + */ + +package com.example.android.uamp + +import android.app.Application +import android.arch.lifecycle.AndroidViewModel +import android.content.ComponentName +import android.support.annotation.NonNull +import android.support.v4.media.MediaBrowserCompat +import android.support.v4.media.session.MediaControllerCompat +import android.util.Log +import com.example.android.uamp.media.MusicService + +/** + * ViewModel that implements (and holds onto) a MediaBrowser connection. + */ +class MediaBrowserViewModel(application: Application) : AndroidViewModel(application) { + + private val mediaBrowser: MediaBrowserCompat + private val mediaBrowserConnectionCallback = MediaBrowserConnectionCallback() + private val mediaControllerCallback = MediaControllerCallback() + + private lateinit var mediaController: MediaControllerCompat + + private val callbacks = ArrayList() + + init { + mediaBrowser = MediaBrowserCompat( + application, + ComponentName(application, MusicService::class.java), + mediaBrowserConnectionCallback, + null) + mediaBrowser.connect() + } + + fun registerCallback(callback: ConnectionCallback) { + if (!callbacks.contains(callback)) { + callbacks.add(callback) + + if (mediaBrowser.isConnected) { + callback.onConnected() + } + } + } + + fun unregisterCallback(callback: ConnectionCallback) { + if (callbacks.contains(callback)) { + callbacks.remove(callback) + } + } + + fun subscribe(parentId: String, callback: MediaBrowserCompat.SubscriptionCallback) { + mediaBrowser.subscribe(parentId, callback) + } + + fun unsubscribe(parentId: String, callback: MediaBrowserCompat.SubscriptionCallback) { + mediaBrowser.unsubscribe(parentId, callback) + } + + fun getRoot(): String { + return mediaBrowser.root + } + + private inner class MediaBrowserConnectionCallback : MediaBrowserCompat.ConnectionCallback() { + override fun onConnected() { + super.onConnected() + + // Get a MediaController for the MediaSession. + mediaController = MediaControllerCompat(getApplication(), mediaBrowser.sessionToken) + mediaController.registerCallback(mediaControllerCallback) + + callbacks.forEach { callback -> callback.onConnected() } + } + + override fun onConnectionSuspended() { + super.onConnectionSuspended() + + callbacks.forEach { callback -> callback.onConnectionSuspended() } + } + + override fun onConnectionFailed() { + super.onConnectionFailed() + + callbacks.forEach { callback -> callback.onConnectionFailed() } + } + } + + private inner class MediaControllerCallback : MediaControllerCompat.Callback() { + override fun onSessionDestroyed() { + super.onSessionDestroyed() + + // Normally if a MediaBrowserService drops its connection the callback comes via + // MediaControllerCompat.Callback (here). But since other connection status events + // are sent to MediaBrowserCompat.ConnectionCallback, we catch the disconnect here + // and send it on to the other callback. + callbacks.forEach { callback -> callback.onConnectionSuspended() } + } + } +} + +/** + * Interface to allow a class to receive callbacks based on the changing state of a + * [MediaBrowser] connection. + */ +interface ConnectionCallback { + fun onConnected() { + } + + fun onConnectionSuspended() { + } + + fun onConnectionFailed() { + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/android/uamp/MediaItemAdapter.kt b/app/src/main/java/com/example/android/uamp/MediaItemAdapter.kt new file mode 100644 index 000000000..6953f2b97 --- /dev/null +++ b/app/src/main/java/com/example/android/uamp/MediaItemAdapter.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2017 Google Inc. All rights reserved. + * + * 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 + * + * http://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. + */ + +package com.example.android.uamp + +import android.support.v4.media.MediaBrowserCompat.MediaItem +import android.support.v7.util.DiffUtil +import android.support.v7.widget.RecyclerView +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import kotlinx.android.synthetic.main.fragment_mediaitem.view.* + +/** + * [RecyclerView.Adapter] of [MediaItem]s used by the [MediaItemFragment]. + */ +class MediaItemAdapter : RecyclerView.Adapter() { + + private var mediaItems = emptyList() + + fun setItems(newList: List) { + // Rather than simply set the new list, use [DiffUtil] to generate changes so + // only items that changed are updated. + val diffResult = DiffUtil.calculateDiff(object : DiffUtil.Callback() { + override fun getOldListSize(): Int = mediaItems.size + + override fun getNewListSize(): Int = newList.size + + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + val oldItem = mediaItems[oldItemPosition].description.mediaId + val newItem = newList[newItemPosition].description.mediaId + + return oldItem == newItem + } + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = + mediaItems[oldItemPosition] == newList[newItemPosition] + }) + + mediaItems = newList + diffResult.dispatchUpdatesTo(this) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MediaViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.fragment_mediaitem, parent, false) + return MediaViewHolder(view) + } + + override fun onBindViewHolder(holder: MediaViewHolder, position: Int) { + holder.mItem = mediaItems[position] + holder.titleView.text = mediaItems[position].description.title + holder.subtitleView.text = mediaItems[position].description.subtitle + } + + override fun getItemCount(): Int = mediaItems.size +} + +class MediaViewHolder(view: View) : RecyclerView.ViewHolder(view) { + val titleView: TextView = view.title + val subtitleView: TextView = view.subtitle + var mItem: MediaItem? = null +} diff --git a/app/src/main/java/com/example/android/uamp/MediaItemFragment.kt b/app/src/main/java/com/example/android/uamp/MediaItemFragment.kt new file mode 100644 index 000000000..9c97ea5b7 --- /dev/null +++ b/app/src/main/java/com/example/android/uamp/MediaItemFragment.kt @@ -0,0 +1,103 @@ +/* + * Copyright 2017 Google Inc. All rights reserved. + * + * 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 + * + * http://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. + */ + +package com.example.android.uamp + +import android.arch.lifecycle.ViewModelProviders +import android.os.Bundle +import android.support.v4.app.Fragment +import android.support.v4.media.MediaBrowserCompat +import android.support.v7.widget.LinearLayoutManager +import android.support.v7.widget.RecyclerView +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup + +private const val MEDIA_ID_ARG = "com.example.android.uamp.MediaItemFragment.MEDIA_ID" + +/** + * A fragment representing a list of MediaItems. + */ +class MediaItemFragment : Fragment(), ConnectionCallback { + private lateinit var mediaId: String + private lateinit var mediaBrowserConnection: MediaBrowserViewModel + + private val subscriptionCallback = SubscriptionCallback() + private val listAdapter = MediaItemAdapter() + + companion object { + + // TODO: Customize parameter initialization + fun newInstance(mediaId: String): MediaItemFragment { + return MediaItemFragment().apply { + arguments = Bundle().apply { + putString(MEDIA_ID_ARG, mediaId) + } + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + retainInstance = true + mediaId = arguments.getString(MEDIA_ID_ARG) + + mediaBrowserConnection = ViewModelProviders.of(this).get(MediaBrowserViewModel::class.java) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle?): View? { + val view = inflater.inflate(R.layout.fragment_mediaitem_list, container, false) + + // Set the adapter + if (view is RecyclerView) { + val context = view.getContext() + + view.layoutManager = LinearLayoutManager(context) + view.adapter = listAdapter + } + return view + } + + override fun onStart() { + super.onStart() + + mediaBrowserConnection.registerCallback(this) + } + + override fun onStop() { + super.onStop() + + mediaBrowserConnection.unsubscribe(mediaId, subscriptionCallback) + mediaBrowserConnection.unregisterCallback(this) + } + + override fun onConnected() { + super.onConnected() + + mediaBrowserConnection.subscribe(mediaId, subscriptionCallback) + } + + private inner class SubscriptionCallback : MediaBrowserCompat.SubscriptionCallback() { + override fun onChildrenLoaded(parentId: String, + children: MutableList) { + super.onChildrenLoaded(parentId, children) + + listAdapter.setItems(children) + } + } +} 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 000000000..7d46d056b --- /dev/null +++ b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_album_black_24dp.xml b/app/src/main/res/drawable/ic_album_black_24dp.xml new file mode 100644 index 000000000..f4d76aece --- /dev/null +++ b/app/src/main/res/drawable/ic_album_black_24dp.xml @@ -0,0 +1,20 @@ + + + + + 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 000000000..88ebf0756 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,187 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 000000000..4e0c3f806 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,34 @@ + + + + + + + diff --git a/app/src/main/res/layout/fragment_mediaitem.xml b/app/src/main/res/layout/fragment_mediaitem.xml new file mode 100644 index 000000000..ecc827294 --- /dev/null +++ b/app/src/main/res/layout/fragment_mediaitem.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_mediaitem_list.xml b/app/src/main/res/layout/fragment_mediaitem_list.xml new file mode 100644 index 000000000..cc147ca99 --- /dev/null +++ b/app/src/main/res/layout/fragment_mediaitem_list.xml @@ -0,0 +1,29 @@ + + + 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 000000000..2884a3cf2 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,21 @@ + + + + + + + \ 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 000000000..2884a3cf2 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,21 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..a2f590828 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 000000000..1b5239980 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..ff10afd6e Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 000000000..115a4c768 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..dcd3cd808 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 000000000..459ca609d Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..8ca12fe02 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 000000000..8e19b410a Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..b824ebdd4 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 000000000..4c19a13c2 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 000000000..99199df6c --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,21 @@ + + + + #3F51B5 + #303F9F + #FF4081 + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml new file mode 100644 index 000000000..f37e8a00f --- /dev/null +++ b/app/src/main/res/values/dimens.xml @@ -0,0 +1,23 @@ + + + + 16dp + + 72dp + 12dp + 72dp + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 000000000..ed6bdaf62 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,18 @@ + + + UAMP + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml new file mode 100644 index 000000000..36a6b7b95 --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + diff --git a/build.gradle b/build.gradle new file mode 100644 index 000000000..5c903bc73 --- /dev/null +++ b/build.gradle @@ -0,0 +1,43 @@ +/* + * Copyright 2017 Google Inc. All rights reserved. + * + * 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 + * + * http://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. + */ + +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + ext.kotlin_version = '1.1.51' + repositories { + google() + jcenter() + } + dependencies { + classpath 'com.android.tools.build:gradle:3.0.1' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +allprojects { + repositories { + google() + jcenter() + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 000000000..45d266f54 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,33 @@ +# +# Copyright 2017 Google Inc. All rights reserved. +# +# 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 +# +# http://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. +# + +# 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=-Xmx1536m + +# 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 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..13372aef5 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 000000000..ded660c2c --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,22 @@ +# +# Copyright 2017 Google Inc. All rights reserved. +# +# 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 +# +# http://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. +# + +#Tue Nov 14 16:01:12 PST 2017 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-4.1-all.zip diff --git a/gradlew b/gradlew new file mode 100755 index 000000000..9d82f7891 --- /dev/null +++ b/gradlew @@ -0,0 +1,160 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# 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 +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# 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 + +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" ] ; 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, switch paths to Windows format before running java +if $cygwin ; 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=$((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 + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 000000000..aec99730b --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,90 @@ +@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 + +@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= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@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 init + +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 init + +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 + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +: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 %CMD_LINE_ARGS% + +: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/media/.gitignore b/media/.gitignore new file mode 100644 index 000000000..796b96d1c --- /dev/null +++ b/media/.gitignore @@ -0,0 +1 @@ +/build diff --git a/media/build.gradle b/media/build.gradle new file mode 100644 index 000000000..d73a3f555 --- /dev/null +++ b/media/build.gradle @@ -0,0 +1,55 @@ +/* + * Copyright 2017 Google Inc. All rights reserved. + * + * 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 + * + * http://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. + */ + +apply plugin: 'com.android.library' + +apply plugin: 'kotlin-android' + +apply plugin: 'kotlin-android-extensions' + +android { + compileSdkVersion 26 + + defaultConfig { + minSdkVersion 19 + targetSdkVersion 26 + versionCode 1 + versionName "1.0" + + testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" + + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + +} + +dependencies { + api "org.jetbrains.kotlin:kotlin-stdlib-jre7:$kotlin_version" + api 'com.google.code.gson:gson:2.8.2' + + api 'com.android.support:support-media-compat:26.1.0' + + testImplementation 'junit:junit:4.12' + + androidTestImplementation 'com.android.support.test:runner:1.0.1' + androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.1' +} diff --git a/media/proguard-rules.pro b/media/proguard-rules.pro new file mode 100644 index 000000000..f1b424510 --- /dev/null +++ b/media/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 diff --git a/media/src/main/AndroidManifest.xml b/media/src/main/AndroidManifest.xml new file mode 100644 index 000000000..7406519df --- /dev/null +++ b/media/src/main/AndroidManifest.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/media/src/main/java/com/example/android/uamp/media/MusicService.kt b/media/src/main/java/com/example/android/uamp/media/MusicService.kt new file mode 100644 index 000000000..ffb85e9f0 --- /dev/null +++ b/media/src/main/java/com/example/android/uamp/media/MusicService.kt @@ -0,0 +1,140 @@ +/* + * Copyright 2017 Google Inc. All rights reserved. + * + * 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 + * + * http://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. + */ + +package com.example.android.uamp.media + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.support.v4.app.NotificationCompat +import android.support.v4.media.MediaBrowserCompat.MediaItem +import android.support.v4.media.MediaBrowserServiceCompat +import android.support.v4.media.MediaDescriptionCompat +import android.support.v4.media.MediaMetadataCompat +import android.support.v4.media.session.MediaSessionCompat +import android.support.v4.media.session.PlaybackStateCompat +import com.example.android.uamp.media.library.JsonSource +import android.support.v4.media.app.NotificationCompat.MediaStyle +import android.support.v4.media.session.MediaButtonReceiver + +/** + * UAMP's implementation of a [MediaBrowserServiceCompat]. + * + * This class is the entry point for browsing and playback commands from the APP's UI + * and other apps that wish to play music via UAMP (for example, Android Auto or + * the Google Assistant). + * + * Browsing begins with the method [MusicService.onGetRoot], and continues in + * the callback [MusicService.onLoadChildren]. + * + * For more information on implementing a MediaBrowserService, + * visit [https://developer.android.com/guide/topics/media-apps/audio-app/building-a-mediabrowserservice.html](https://developer.android.com/guide/topics/media-apps/audio-app/building-a-mediabrowserservice.html). + */ +class MusicService : MediaBrowserServiceCompat() { + + private lateinit var mediaSession: MediaSessionCompat + private lateinit var mediaSessionCallback: MediaSessionCallback + + private lateinit var mediaSource: JsonSource + + private val remoteJsonSource: Uri = + Uri.parse("https://storage.googleapis.com/automotive-media/music.json") + + override fun onCreate() { + super.onCreate() + + // Create a new MediaSession. + mediaSession = MediaSessionCompat(this, "MusicService") + mediaSessionCallback = MediaSessionCallback() + mediaSession.setCallback(mediaSessionCallback) + mediaSession.setFlags( + MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or + MediaSessionCompat.FLAG_HANDLES_QUEUE_COMMANDS or + MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS) + sessionToken = mediaSession.sessionToken + + mediaSource = JsonSource(context = this, source = remoteJsonSource) + } + + override fun onTaskRemoved(rootIntent: Intent) { + super.onTaskRemoved(rootIntent) + stopSelf() + } + + override fun onDestroy() { + mediaSession.release() + } + + override fun onGetRoot(clientPackageName: String, + clientUid: Int, + rootHints: Bundle?): MediaBrowserServiceCompat.BrowserRoot? { + return BrowserRoot("/", null) + } + + override fun onLoadChildren( + parentMediaId: String, + result: MediaBrowserServiceCompat.Result>) { + + val resultsSent = mediaSource.whenReady { successfullyInitialized -> + if (successfullyInitialized) { + val children = mediaSource.catalog.map { item -> + MediaItem(item.description, MediaItem.FLAG_PLAYABLE) + } + result.sendResult(children) + } else { + result.sendError(null) + } + } + + if (!resultsSent) { + result.detach() + } + } + + // MediaSession Callback: Transport Controls -> MediaPlayerAdapter + inner class MediaSessionCallback : MediaSessionCompat.Callback() { + override fun onAddQueueItem(description: MediaDescriptionCompat?) { + } + + override fun onRemoveQueueItem(description: MediaDescriptionCompat?) { + } + + override fun onPrepare() { + } + + override fun onPlay() { + } + + override fun onPause() { + } + + override fun onStop() { + } + + override fun onSkipToNext() { + } + + override fun onSkipToPrevious() { + } + + override fun onSeekTo(pos: Long) { + } + } +} \ No newline at end of file diff --git a/media/src/main/java/com/example/android/uamp/media/library/AbstractMusicSource.kt b/media/src/main/java/com/example/android/uamp/media/library/AbstractMusicSource.kt new file mode 100644 index 000000000..2a93c62b5 --- /dev/null +++ b/media/src/main/java/com/example/android/uamp/media/library/AbstractMusicSource.kt @@ -0,0 +1,88 @@ +/* + * Copyright 2017 Google Inc. All rights reserved. + * + * 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 + * + * http://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. + */ + +package com.example.android.uamp.media.library + +import android.content.Context +import android.support.annotation.IntDef +import android.util.Log + +@IntDef(STATE_CREATED, + STATE_INITIALIZING, + STATE_INITIALIZED, + STATE_ERROR) +@Retention(AnnotationRetention.SOURCE) +annotation class State + +/** + * State indicating the source was created, but no initalization has performed. + */ +const val STATE_CREATED = 1L + +/** + * State indicating initalization of the source is in progress. + */ +const val STATE_INITIALIZING = 2L + +/** + * State indicating the source has been initialized and is ready to be used. + */ +const val STATE_INITIALIZED = 3L + +/** + * State indicating an error has occurred. + */ +const val STATE_ERROR = 4L + +/** + * Base class for music sources in UAMP. + */ +abstract class AbstractMusicSource(val context: Context) { + @State + var state: Long = STATE_CREATED + set(value) { + if (value == STATE_INITIALIZED || value == STATE_ERROR) { + synchronized(onReadyListeners) { + field = value + onReadyListeners.forEach { listener -> + listener(state == STATE_INITIALIZED) + } + } + } else { + field = value + } + } + + val onReadyListeners = mutableListOf<(Boolean) -> Unit>() + + /** + * Performs an action when this MusicSource is ready. + * + * This method is *not* threadsafe. Ensure actions and state changes are only performed + * on a single thread. + */ + fun whenReady(performAction: (Boolean) -> Unit): Boolean = + when (state) { + STATE_CREATED, STATE_INITIALIZING -> { + onReadyListeners += performAction + false + } + else -> { + performAction(state != STATE_ERROR) + true + } + } +} \ No newline at end of file diff --git a/media/src/main/java/com/example/android/uamp/media/library/JsonSource.kt b/media/src/main/java/com/example/android/uamp/media/library/JsonSource.kt new file mode 100644 index 000000000..92a0d67b4 --- /dev/null +++ b/media/src/main/java/com/example/android/uamp/media/library/JsonSource.kt @@ -0,0 +1,187 @@ +/* + * Copyright 2017 Google Inc. All rights reserved. + * + * 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 + * + * http://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. + */ + +package com.example.android.uamp.media.library + +import android.content.Context +import android.net.Uri +import android.os.AsyncTask +import android.support.v4.media.MediaMetadataCompat +import android.util.Log +import com.google.gson.Gson +import java.io.BufferedReader +import java.io.InputStreamReader +import java.net.URL +import java.util.concurrent.TimeUnit + +/** + * Source of [MediaMetadataCompat] objects created from a basic JSON stream. + * + * The definition of the JSON is specified in the docs of [JsonMusic] in this file, + * which is the object representation of it. + */ +class JsonSource(context: Context, source: Uri) : AbstractMusicSource(context) { + var catalog: List = emptyList() + + init { + state = STATE_INITIALIZING + + UpdateCatalogTask { mediaItems -> + catalog = mediaItems + state = STATE_INITIALIZED + }.execute(source) + } +} + +/** + * Task to connect to remote URIs and download/process JSON files that correspond to + * [MediaMetadataCompat] objects. + */ +private class UpdateCatalogTask(val listener: (List) -> Unit) : + AsyncTask>() { + + override fun doInBackground(vararg params: Uri): List { + val gson = Gson() + val mediaItems = ArrayList() + + params.forEach { catalogUri -> + val catalogConn = URL(catalogUri.toString()) + val reader = BufferedReader(InputStreamReader(catalogConn.openStream())) + val musicCat = gson.fromJson(reader, JsonCatalog::class.java) + + // Get the base URI to fix up relative references later. + val baseUri = catalogUri.toString().removeSuffix(catalogUri.lastPathSegment) + + mediaItems += musicCat.music.map { song -> + // The JSON may have paths that are relative to the source of the JSON + // itself. We need to fix them up here to turn them into absolute paths. + if (!song.source.startsWith(catalogUri.scheme)) { + song.source = baseUri + song.source + } + if (!song.image.startsWith(catalogUri.scheme)) { + song.image = baseUri + song.image + } + + MediaMetadataCompat.Builder() + .from(song) + .build() + }.toList() + } + + return mediaItems + } + + override fun onPostExecute(mediaItems: List) { + super.onPostExecute(mediaItems) + listener(mediaItems) + } +} + +/** + * Extension method for [MediaMetadataCompat.Builder] to set the fields from + * our JSON constructed object (to make the code a bit easier to see). + */ +fun MediaMetadataCompat.Builder.from(jsonMusic: JsonMusic): MediaMetadataCompat.Builder { + // The duration from the JSON is given in seconds, but the rest of the code works in + // milliseconds. Here's where we convert to the proper units. + val duration: Long = TimeUnit.SECONDS.toMillis(jsonMusic.duration) + + with(jsonMusic) { + // Setup the core properties that will be used by the media library. + this@from + .putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id) + .putString(MediaMetadataCompat.METADATA_KEY_TITLE, title) + .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, artist) + .putString(MediaMetadataCompat.METADATA_KEY_ALBUM, album) + .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, duration) + .putString(MediaMetadataCompat.METADATA_KEY_GENRE, genre) + .putString(MediaMetadataCompat.METADATA_KEY_MEDIA_URI, source) + .putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, image) + .putLong(MediaMetadataCompat.METADATA_KEY_TRACK_NUMBER, trackNumber) + .putLong(MediaMetadataCompat.METADATA_KEY_NUM_TRACKS, totalTrackCount) + + // To make things easier for *displaying* these, set the display properties as well. + .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title) + .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, artist) + .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, album) + .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, image) + } + + // Allow it to be used in the typical builder style. + return this +} + +/** + * Wrapper object for our JSON in order to be processed easily by GSON. + */ +class JsonCatalog { + var music: List = ArrayList() +} + +/** + * An individual piece of music included in our JSON catalog. + * The format from the server is as specified: + * ``` + * { "music" : [ + * { "title" : // Title of the piece of music + * "album" : // Album title of the piece of music + * "artist" : // Artist of the piece of music + * "genre" : // Primary genre of the music + * "source" : // Path to the music, which may be relative + * "image" : // Path to the art for the music, which may be relative + * "trackNumber" : // Track number + * "totalTrackCount" : // Track count + * "duration" : // Duration of the music in seconds + * "site" : // Source of the music, if applicable + * } + * ]} + * ``` + * + * `source` and `image` can be provided in either relative or + * absolute paths. For example: + * `` + * "source" : "https://www.example.com/music/ode_to_joy.mp3", + * "image" : "ode_to_joy.jpg" + * `` + * + * The `source` specifies the full URI to download the piece of music from, but + * `image` will be fetched relative to the path of the JSON file itself. This means + * that if the JSON was at "https://www.example.com/json/music.json" then the image would be found + * at "https://www.example.com/json/ode_to_joy.jpg". + */ +class JsonMusic { + var id: String = "" + get() { + // It's best if the media provider supplies a unique ID for the media, but in case + // they don't, generate our own using the object's hash code. + if (field.isEmpty()) { + id = "id#${hashCode()}" + } + return field + } + + var title: String = "" + var album: String = "" + var artist: String = "" + var genre: String = "" + var source: String = "" + var image: String = "" + var trackNumber: Long = 0 + var totalTrackCount: Long = 0 + var duration: Long = -1 + var site: String = "" +} + diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 000000000..4decc398e --- /dev/null +++ b/settings.gradle @@ -0,0 +1,17 @@ +/* + * Copyright 2017 Google Inc. All rights reserved. + * + * 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 + * + * http://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. + */ + +include ':app', ':media'