@@ -1,198 +1,192 @@
package com.commit451.youtubeextractor

import com.github.salomonbrys.kotson.fromJson
import com.google.gson.Gson
//import com.squareup.moshi.Moshi
import okhttp3.OkHttpClient
import okhttp3.Request
import org.jsoup.Jsoup
import org.jsoup.parser.Parser


/**
* Class that allows you to extract stream data from a YouTube video
* given its video id, which is typically contained within the YouTube video url, ie. https://www.youtube.com/watch?v=dQw4w9WgXcQ
* has a video id of dQw4w9WgXcQ
*/
class YouTubeExtractor private constructor(builder: Builder) {

companion object {

private const val BASE_URL = "https://www.youtube.com"

/**
* Extract the thumbnails for the video. This will be done if you call
* [extract] but since it is a lightweight operation, you can do it
* synchronously if you choose
*/
fun extractThumbnails(videoId: String): List<Thumbnail> {
return YouTubeImageHelper.extractAll(videoId)
}

/**
* Extract a thumbnail of a specific quality. See qualities within [Thumbnail]
*/
fun extractThumbnail(videoId: String, quality: String): Thumbnail {
return YouTubeImageHelper.extract(videoId, quality)
}

/**
* Create a new YouTubeExtractor with a custom OkHttp client builder
* @return a new [YouTubeExtractor]
*/
@Deprecated("Use the Builder class instead", ReplaceWith("YouTubeExtractor.Builder().okHttpClientBuilder(okHttpBuilder).build()"))
@JvmOverloads
fun create(okHttpBuilder: OkHttpClient.Builder? = null): YouTubeExtractor {
return YouTubeExtractor.Builder()
.okHttpClientBuilder(okHttpBuilder)
.build()
}
}

private var client: OkHttpClient
// private var moshi: Moshi
private var gson: Gson
private var debug = false

init {
this.debug = builder.debug
val clientBuilder = builder.okHttpClientBuilder ?: OkHttpClient.Builder()
client = clientBuilder.build()
// moshi = Moshi.Builder()
//class YouTubeExtractor private constructor(builder: Builder) {
//
// companion object {
//
// private const val BASE_URL = "https://www.youtube.com"
//
// /**
// * Extract the thumbnails for the video. This will be done if you call
// * [extract] but since it is a lightweight operation, you can do it
// * synchronously if you choose
// */
// fun extractThumbnails(videoId: String): List<Thumbnail> {
// return YouTubeImageHelper.extractAll(videoId)
// }
//
// /**
// * Extract a thumbnail of a specific quality. See qualities within [Thumbnail]
// */
// fun extractThumbnail(videoId: String, quality: String): Thumbnail {
// return YouTubeImageHelper.extract(videoId, quality)
// }
//
// /**
// * Create a new YouTubeExtractor with a custom OkHttp client builder
// * @return a new [YouTubeExtractor]
// */
// @Deprecated("Use the Builder class instead", ReplaceWith("YouTubeExtractor.Builder().okHttpClientBuilder(okHttpBuilder).build()"))
// @JvmOverloads
// fun create(okHttpBuilder: OkHttpClient.Builder? = null): YouTubeExtractor {
// return YouTubeExtractor.Builder()
// .okHttpClientBuilder(okHttpBuilder)
// .build()
// }
// }
//
// private var client: OkHttpClient
//// private var moshi: Moshi
// private var gson: Gson
// private var debug = false
//
// init {
// this.debug = builder.debug
// val clientBuilder = builder.okHttpClientBuilder ?: OkHttpClient.Builder()
// client = clientBuilder.build()
//// moshi = Moshi.Builder()
//// .build()
// gson = Gson()
// }
//
// /**
// * Extract the video information
// * @param videoId the video ID
// * @return the extracted result
// */
// fun extract(videoId: String): YouTubeExtraction {
// val url = "$BASE_URL/watch?v=$videoId"
// log("Extracting from URL $url")
// val pageContent = urlToString(url)
// val doc = Jsoup.parse(pageContent)
//
// val ytPlayerConfigJson = Util.matchGroup("ytplayer.config\\s*=\\s*(\\{.*?\\});", pageContent, 1)
//// val ytPlayerConfig = moshi.adapter<PlayerConfig>(PlayerConfig::class.java).fromJson(ytPlayerConfigJson)!!
// val ytPlayerConfig = gson.fromJson<PlayerConfig>(ytPlayerConfigJson)
// val playerArgs = ytPlayerConfig.args!!
// val playerUrl = formatPlayerUrl(ytPlayerConfig)
// val videoStreams = parseVideoStreams(playerArgs, playerUrl)
// val description = tryIgnoringException { doc.select("p[id=\"eow-description\"]").first().html() }
// return YouTubeExtraction(videoId,
// playerArgs.title,
// videoStreams,
// extractThumbnails(videoId),
// playerArgs.author,
// description,
// playerArgs.viewCount?.toLongOrNull(),
// playerArgs.lengthSeconds?.toLongOrNull())
// }
//
// private fun tryIgnoringException(block: () -> String): String? {
// try {
// return block.invoke()
// } catch (e: Exception) {
// //we tried our best
// log(e.message)
// }
// return null
// }
//
// private fun urlToString(url: String): String {
// val request = Request.Builder()
// .url(url)
// .build()
gson = Gson()
}

/**
* Extract the video information
* @param videoId the video ID
* @return the extracted result
*/
fun extract(videoId: String): YouTubeExtraction {
val url = "$BASE_URL/watch?v=$videoId"
log("Extracting from URL $url")
val pageContent = urlToString(url)
val doc = Jsoup.parse(pageContent)

val ytPlayerConfigJson = Util.matchGroup("ytplayer.config\\s*=\\s*(\\{.*?\\});", pageContent, 1)
// val ytPlayerConfig = moshi.adapter<PlayerConfig>(PlayerConfig::class.java).fromJson(ytPlayerConfigJson)!!
val ytPlayerConfig = gson.fromJson<PlayerConfig>(ytPlayerConfigJson)
val playerArgs = ytPlayerConfig.args!!
val playerUrl = formatPlayerUrl(ytPlayerConfig)
val videoStreams = parseVideoStreams(playerArgs, playerUrl)
val description = tryIgnoringException { doc.select("p[id=\"eow-description\"]").first().html() }
return YouTubeExtraction(videoId,
playerArgs.title,
videoStreams,
extractThumbnails(videoId),
playerArgs.author,
description,
playerArgs.viewCount?.toLongOrNull(),
playerArgs.lengthSeconds?.toLongOrNull())
}

private fun tryIgnoringException(block: () -> String): String? {
try {
return block.invoke()
} catch (e: Exception) {
//we tried our best
log(e.message)
}
return null
}

private fun urlToString(url: String): String {
val request = Request.Builder()
.url(url)
.build()

return client.newCall(request)
.execute()
.body()
?.string() ?: throw Exception("Unable to connect")
}

private fun formatPlayerUrl(playerConfig: PlayerConfig): String {
var playerUrl = playerConfig.assets?.js!!

if (playerUrl.startsWith("//")) {
playerUrl = "https:$playerUrl"
}
if (!playerUrl.startsWith(BASE_URL)) {
playerUrl = BASE_URL + playerUrl
}
return playerUrl
}

private fun parseVideoStreams(playerArgs: PlayerArgs, playerUrl: String): List<VideoStream> {
val itags = parseVideoItags(playerArgs, playerUrl)
return itags.map { VideoStream(it.key, it.value.format, it.value.resolution) }
}

private fun parseVideoItags(playerArgs: PlayerArgs, playerUrl: String): Map<String, ItagItem> {
val urlAndItags = LinkedHashMap<String, ItagItem>()
val encodedUrlMap = playerArgs.urlEncodedFmtStreamMap ?: ""
val validUrlData = encodedUrlMap.split(",".toRegex()).filter { it.isNotEmpty() }
for (urlDataStr in validUrlData) {

val tags = Util.compatParseMap(Parser.unescapeEntities(urlDataStr, true))

val itag = tags["itag"]?.toInt()

if (ItagItem.isSupported(itag)) {
val itagItem = ItagItem.getItag(itag)
var streamUrl = tags["url"]
val signature = tags["s"]
if (signature != null) {
log("Signature not found, decrypting signature for $streamUrl")
//TODO remove the need to remove all \n. It breaks the regex we have
val playerCode = urlToString(playerUrl)
.replace("\n", "")
streamUrl = streamUrl + "&signature=" + JavaScriptUtil.decryptSignature(signature, JavaScriptUtil.loadDecryptionCode(playerCode))
}
if (streamUrl != null) {
urlAndItags[streamUrl] = itagItem
}
}
}

return urlAndItags
}

private fun log(string: String?) {
if (debug) {
println(string)
}
}

/**
* Builds a [YouTubeExtractor] instance
*/
class Builder {
internal var debug = false
internal var okHttpClientBuilder: OkHttpClient.Builder? = null

/**
* Forces logging to show for the [YouTubeExtractor]
*/
fun debug(debug: Boolean): Builder {
this.debug = debug
return this
}

/**
* Set a custom [OkHttpClient.Builder] on the [YouTubeExtractor]
*/
fun okHttpClientBuilder(okHttpClientBuilder: OkHttpClient.Builder?): Builder {
this.okHttpClientBuilder = okHttpClientBuilder
return this
}

/**
* Create the configured [YouTubeExtractor]
*/
fun build(): YouTubeExtractor {
return YouTubeExtractor(this)
}
}
}
//
// return client.newCall(request)
// .execute()
// .body()
// ?.string() ?: throw Exception("Unable to connect")
// }
//
// private fun formatPlayerUrl(playerConfig: PlayerConfig): String {
// var playerUrl = playerConfig.assets?.js!!
//
// if (playerUrl.startsWith("//")) {
// playerUrl = "https:$playerUrl"
// }
// if (!playerUrl.startsWith(BASE_URL)) {
// playerUrl = BASE_URL + playerUrl
// }
// return playerUrl
// }
//
// private fun parseVideoStreams(playerArgs: PlayerArgs, playerUrl: String): List<VideoStream> {
// val itags = parseVideoItags(playerArgs, playerUrl)
// return itags.map { VideoStream(it.key, it.value.format, it.value.resolution) }
// }
//
// private fun parseVideoItags(playerArgs: PlayerArgs, playerUrl: String): Map<String, ItagItem> {
// val urlAndItags = LinkedHashMap<String, ItagItem>()
// val encodedUrlMap = playerArgs.urlEncodedFmtStreamMap ?: ""
// val validUrlData = encodedUrlMap.split(",".toRegex()).filter { it.isNotEmpty() }
// for (urlDataStr in validUrlData) {
//
// val tags = Util.compatParseMap(Parser.unescapeEntities(urlDataStr, true))
//
// val itag = tags["itag"]?.toInt()
//
// if (ItagItem.isSupported(itag)) {
// val itagItem = ItagItem.getItag(itag)
// var streamUrl = tags["url"]
// val signature = tags["s"]
// if (signature != null) {
// log("Signature not found, decrypting signature for $streamUrl")
// //TODO remove the need to remove all \n. It breaks the regex we have
// val playerCode = urlToString(playerUrl)
// .replace("\n", "")
// streamUrl = streamUrl + "&signature=" + JavaScriptUtil.decryptSignature(signature, JavaScriptUtil.loadDecryptionCode(playerCode))
// }
// if (streamUrl != null) {
// urlAndItags[streamUrl] = itagItem
// }
// }
// }
//
// return urlAndItags
// }
//
// private fun log(string: String?) {
// if (debug) {
// println(string)
// }
// }
//
// /**
// * Builds a [YouTubeExtractor] instance
// */
// class Builder {
// internal var debug = false
// internal var okHttpClientBuilder: OkHttpClient.Builder? = null
//
// /**
// * Forces logging to show for the [YouTubeExtractor]
// */
// fun debug(debug: Boolean): Builder {
// this.debug = debug
// return this
// }
//
// /**
// * Set a custom [OkHttpClient.Builder] on the [YouTubeExtractor]
// */
// fun okHttpClientBuilder(okHttpClientBuilder: OkHttpClient.Builder?): Builder {
// this.okHttpClientBuilder = okHttpClientBuilder
// return this
// }
//
// /**
// * Create the configured [YouTubeExtractor]
// */
// fun build(): YouTubeExtractor {
// return YouTubeExtractor(this)
// }
// }
//}
@@ -4,21 +4,22 @@ import android.app.Application
import android.arch.lifecycle.ViewModelProvider
import android.arch.lifecycle.ViewModelStore
import android.content.Context
import android.content.Intent
import android.net.*
import android.support.annotation.MainThread
import android.support.v4.app.Fragment
import android.util.DisplayMetrics
import com.chibatching.kotpref.Kotpref
import com.esotericsoftware.kryo.Kryo
import com.esotericsoftware.kryo.serializers.FieldSerializer
import com.evernote.android.state.StateSaver
import com.loafofpiecrust.turntable.service.Library
import com.loafofpiecrust.turntable.service.OnlineSearchService
import com.loafofpiecrust.turntable.service.SyncService
import com.loafofpiecrust.turntable.util.CBCSerializer
import com.loafofpiecrust.turntable.util.distinctSeq
import com.loafofpiecrust.turntable.util.task
import com.loafofpiecrust.turntable.util.threadLocalLazy
import com.squareup.leakcanary.LeakCanary
import de.javakaffee.kryoserializers.ArraysAsListSerializer
import de.javakaffee.kryoserializers.SubListSerializers
import de.javakaffee.kryoserializers.UUIDSerializer
@@ -84,8 +85,19 @@ class App: Application() {
super.onCreate()
_instance = WeakReference(this)

if (LeakCanary.isInAnalyzerProcess(this)) {
// This process is dedicated to LeakCanary for heap analysis.
// You should not init your app in this process.
return
}
LeakCanary.install(this)

StateSaver.setEnabledForAllActivitiesAndSupportFragments(this, true)

Kotpref.init(this)
// library = Library()
library = Library().apply {
onCreate()
}
search = OnlineSearchService()

search.onCreate()
@@ -94,7 +106,7 @@ class App: Application() {

// Start the MusicService
// startService(Intent(this, MusicService::class.java))
startService(Intent(this, Library::class.java))
// startService(Intent(this, Library::class.java))
SyncService.initDeviceId()
// startService(Intent(this, OnlineSearchService::class.java))
// startService(Intent(this, FileSyncService::class.java))
@@ -3,19 +3,18 @@ package com.loafofpiecrust.turntable
//import quatja.com.vorolay.VoronoiView
import android.content.BroadcastReceiver
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.graphics.Color
import android.graphics.PorterDuff
import android.support.v4.app.Fragment
import android.support.v4.app.FragmentManager
import android.support.annotation.ColorInt
import android.support.v4.view.ViewPager
import android.support.v7.widget.PopupMenu
import android.support.v7.widget.Toolbar
import android.view.*
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.TextView
import com.arlib.floatingsearchview.FloatingSearchView
import com.esotericsoftware.kryo.Kryo
import com.esotericsoftware.kryo.io.Input
import com.esotericsoftware.kryo.io.Output
@@ -40,13 +39,11 @@ import kotlinx.coroutines.experimental.Job
import kotlinx.coroutines.experimental.Runnable
import kotlinx.coroutines.experimental.android.UI
import kotlinx.coroutines.experimental.channels.*
import org.jetbrains.anko.AnkoViewDslMarker
import org.jetbrains.anko.backgroundColor
import org.jetbrains.anko.childrenSequence
import kotlinx.coroutines.experimental.launch
import org.jetbrains.anko.*
import org.jetbrains.anko.custom.ankoView
import org.jetbrains.anko.sdk25.coroutines.onClick
import org.jetbrains.anko.support.v4.onPageChangeListener
import org.jetbrains.anko.wrapContent
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.util.*
@@ -67,9 +64,6 @@ inline fun ViewManager.fastScrollRecycler(init: @AnkoViewDslMarker FastScrollRec
inline fun ViewManager.recyclerViewPager(init: @AnkoViewDslMarker RecyclerViewPager.() -> Unit): RecyclerViewPager =
ankoView({ RecyclerViewPager(it) }, theme = 0, init = init)

inline fun ViewManager.floatingSearchView(theme: Int = 0, init: FloatingSearchView.() -> Unit = {}): FloatingSearchView =
ankoView({ FloatingSearchView(it) }, theme, init)

inline fun ViewManager.searchBar(theme: Int = 0, init: SearchView.() -> Unit = {}): SearchView =
ankoView({ SearchView(it) }, theme, init)
//fun ViewManager.voronoiView(theme: Int = 0, init: @AnkoViewDslMarker VoronoiView.() -> Unit = {}): VoronoiView =
@@ -94,21 +88,6 @@ inline fun ViewManager.circularProgressBar(theme: Int = 0, init: @AnkoViewDslMar
//fun ViewManager.playbackControlView(theme: Int = 0, init: PlaybackControlView.() -> Unit = {}): FloatingSearchView =
// ankoView({ FloatingSearchView(it) }, theme = theme, init = init)


inline fun <reified T: Fragment> View.fragment(
manager: FragmentManager?,
fragment: T
): T {
if (this.id == View.NO_ID) {
this.id = View.generateViewId()
}
manager?.beginTransaction()
?.add(this.id, fragment)
?.commit()
// fragment.view?.init()
return fragment
}

fun Toolbar.menuItem(
title: String,
iconId: Int? = null,
@@ -202,7 +181,7 @@ fun MenuItem.onClick(
handler: suspend (v: MenuItem) -> Unit
) {
setOnMenuItemClickListener { v ->
task(context) {
launch(context) {
handler.invoke(v)
}
true
@@ -234,7 +213,7 @@ inline fun <T> List<T>.without(pos: Int): List<T>
= take(pos) + drop(pos + 1)
inline fun <T> List<T>.withoutFirst(picker: (T) -> Boolean): List<T> {
val pos = indexOfFirst(picker)
return take(pos) + drop(pos + 1)
return without(pos)
}
fun <T> List<T>.withoutElem(elem: T): List<T> {
val idx = indexOfFirst { it === elem }
@@ -522,7 +501,7 @@ fun List<String>.longestSharedSuffix(ignoreCase: Boolean = false): String? {
}

infix fun <T> T.provided(b: Boolean): T? = if (b) this else null
infix fun <T> T.provided(block: (T) -> Boolean): T? = if (block(this)) this else null
inline infix fun <T> T.provided(block: (T) -> Boolean): T? = if (block(this)) this else null

inline fun <reified T: Any> GsonBuilder.registerSubjectType() = run {
registerTypeAdapter<ConflatedBroadcastChannel<T>> {
@@ -708,4 +687,39 @@ fun View.generateChildrenIds() {
it.id = View.generateViewId()
}
}
}

@get:ColorInt
val Int.complementaryColor: Int get() {
val hsv = FloatArray(3)
Color.RGBToHSV(Color.red(this), Color.green(this),
Color.blue(this), hsv)
hsv[0] = (hsv[0] + 180) % 360
return Color.HSVToColor(hsv)
}

inline fun <T: Any> Context.selector(
prompt: String,
options: List<Pair<T, (DialogInterface, T) -> Unit>>,
format: (T) -> String = { it.toString() }
): Unit = selector(prompt, options.map { format(it.first) }) { dialog, idx ->
val (choice, cb) = options[idx]
cb(dialog, choice)
}

inline fun <T: Any> Context.selector(
prompt: String,
options: List<Pair<Pair<T, String>, (DialogInterface, T) -> Unit>>
): Unit = selector(prompt, options.map { it.first.second }) { dialog, idx ->
val (choice, cb) = options[idx]
cb(dialog, choice.first)
}

inline fun <T: Any> Context.selector(
prompt: String,
options: List<Pair<T, String>>,
crossinline cb: (DialogInterface, T) -> Unit
): Unit = selector(prompt, options.map { it.second }) { dialog, idx ->
val (choice, name) = options[idx]
cb(dialog, choice)
}
@@ -1,5 +1,6 @@
package com.loafofpiecrust.turntable.album

import android.content.Context
import android.graphics.drawable.Drawable
import android.view.Menu
import com.bumptech.glide.RequestBuilder
@@ -10,26 +11,19 @@ import com.loafofpiecrust.turntable.service.Library
import com.loafofpiecrust.turntable.service.library
import com.loafofpiecrust.turntable.song.Song
import com.loafofpiecrust.turntable.ui.AlbumEditorActivityStarter
import com.loafofpiecrust.turntable.ui.MainActivity
import com.loafofpiecrust.turntable.util.ALT_BG_POOL
import com.loafofpiecrust.turntable.util.BG_POOL
import kotlinx.android.parcel.Parcelize
import com.loafofpiecrust.turntable.util.consumeEach
import kotlinx.coroutines.experimental.android.UI
import kotlinx.coroutines.experimental.channels.ReceiveChannel
import kotlinx.coroutines.experimental.channels.first
import kotlinx.coroutines.experimental.channels.map
import kotlinx.coroutines.experimental.channels.produce
import kotlinx.coroutines.experimental.runBlocking
import org.jetbrains.anko.ctx
import org.jetbrains.anko.toast

data class Genre(val name: String)

//interface Song {
// val year: Int?
//}


@Parcelize
data class LocalAlbum(
override val id: AlbumId,
override val tracks: List<Song>
@@ -62,17 +56,15 @@ data class LocalAlbum(
}
}

override fun optionsMenu(menu: Menu) {
super.optionsMenu(menu)
override fun optionsMenu(ctx: Context, menu: Menu) {
super.optionsMenu(ctx, menu)

val ctx = MainActivity.latest.ctx
menu.menuItem("Edit Tags").onClick {
AlbumEditorActivityStarter.start(ctx, this@LocalAlbum)
AlbumEditorActivityStarter.start(ctx, id)
}
}
}

@Parcelize
open class RemoteAlbum(
override val id: AlbumId,
open val remoteId: Album.RemoteDetails, // Discogs, Spotify, or MusicBrainz ID
@@ -127,10 +119,9 @@ open class RemoteAlbum(
?: Library.instance.loadAlbumCover(req, id)
}

override fun optionsMenu(menu: Menu) {
super.optionsMenu(menu)
override fun optionsMenu(ctx: Context, menu: Menu) {
super.optionsMenu(ctx, menu)

val ctx = MainActivity.latest.ctx
menu.menuItem("Download", R.drawable.ic_cloud_download, showIcon=false).onClick(ALT_BG_POOL) {
if (App.instance.hasInternet) {
given(ctx.library.findCachedAlbum(id).first()?.tracks) { tracks ->
@@ -142,10 +133,28 @@ open class RemoteAlbum(
ctx.toast("No internet connection")
}
}

menu.menuItem("Add to Library", R.drawable.ic_turned_in_not, showIcon = true) {
ctx.library.findAlbum(id).consumeEach(UI) { existing ->
if (existing != null) {
icon = ctx.getDrawable(R.drawable.ic_turned_in)
onClick {
// Remove remote album from library
ctx.library.removeRemoteAlbum(existing)
ctx.toast("Removed album to library")
}
} else {
icon = ctx.getDrawable(R.drawable.ic_turned_in_not)
onClick {
ctx.library.addRemoteAlbum(this@RemoteAlbum)
ctx.toast("Added album to library")
}
}
}
}
}
}

@Parcelize
data class MergedAlbum(
override val id: AlbumId,
override val remoteId: Album.RemoteDetails,
@@ -1,8 +1,10 @@
package com.loafofpiecrust.turntable.album

//import com.loafofpiecrust.turntable.model.PaperParcelAlbum
import android.content.Context
import android.graphics.drawable.Drawable
import android.os.Parcelable
import android.support.design.widget.CollapsingToolbarLayout
import android.support.v7.graphics.Palette
import android.support.v7.widget.CardView
import android.support.v7.widget.Toolbar
@@ -21,22 +23,19 @@ import com.loafofpiecrust.turntable.menuItem
import com.loafofpiecrust.turntable.onClick
import com.loafofpiecrust.turntable.player.MusicPlayer
import com.loafofpiecrust.turntable.player.MusicService
import com.loafofpiecrust.turntable.playlist.PlaylistPickerDialogStarter
import com.loafofpiecrust.turntable.playlist.PlaylistPickerDialog
import com.loafofpiecrust.turntable.prefs.UserPrefs
import com.loafofpiecrust.turntable.service.Library
import com.loafofpiecrust.turntable.service.SyncService
import com.loafofpiecrust.turntable.song.*
import com.loafofpiecrust.turntable.sync.FriendPickerDialog
import com.loafofpiecrust.turntable.ui.MainActivity
import com.loafofpiecrust.turntable.util.compareValuesByIgnoreCase
import com.loafofpiecrust.turntable.util.success
import com.loafofpiecrust.turntable.sync.FriendPickerDialogStarter
import com.loafofpiecrust.turntable.util.task
import com.loafofpiecrust.turntable.util.then
import kotlinx.android.parcel.Parcelize
import kotlinx.coroutines.experimental.channels.ReceiveChannel
import kotlinx.coroutines.experimental.channels.first
import kotlinx.coroutines.experimental.channels.map
import org.jetbrains.anko.backgroundColor
import org.jetbrains.anko.ctx
import org.jetbrains.anko.textColor
import java.io.Serializable
import java.util.*
@@ -135,11 +134,8 @@ data class AlbumId(
res
}

override fun compareTo(other: AlbumId): Int {
return compareValuesByIgnoreCase(this, other, arrayOf(
{ it -> it.sortTitle }, { it -> it.artist.sortName }
))
}
override fun compareTo(other: AlbumId): Int
= Library.ALBUM_COMPARATOR.compare(this, other)
}

//@PaperParcel
@@ -319,13 +315,12 @@ data class AlbumId(

fun loadPalette(id: MusicId, views: Array<out View>) =
loadPalette(id) { palette, swatch ->
val color = if (swatch == null) {
val color = if (palette == null || swatch == null) {
val textColor = views[0].resources.getColor(R.color.text)
views.forEach {
if (it is Toolbar) {
it.setTitleTextColor(textColor)
} else if (it is TextView) {
it.textColor = textColor
when (it) {
is Toolbar -> it.setTitleTextColor(textColor)
is TextView -> it.textColor = textColor
}
}
// view.resources.getColor(R.color.primary)
@@ -342,10 +337,10 @@ fun loadPalette(id: MusicId, views: Array<out View>) =
swatch.rgb
}
views.forEach {
if (it is CardView) {
it.setCardBackgroundColor(color)
} else if (it !is TextView) {
it.backgroundColor = color
when (it) {
is CardView -> it.setCardBackgroundColor(color)
is CollapsingToolbarLayout -> it.setContentScrimColor(color)
!is TextView -> it.backgroundColor = color
}
}
}
@@ -415,13 +410,11 @@ interface Album: Music {
fun mergeWith(other: Album): Album

override val simpleName: String get() = id.displayName
override fun optionsMenu(menu: Menu) {
val ctx = MainActivity.latest.ctx

override fun optionsMenu(ctx: Context, menu: Menu) {
menu.menuItem("Shuffle", R.drawable.ic_shuffle, showIcon=false).onClick {
task {
Library.instance.songsOnAlbum(id).first()
}.success { tracks ->
}.then { tracks ->
given(tracks) {
if (it.isNotEmpty()) {
MusicService.enact(SyncService.Message.PlaySongs(it, mode = MusicPlayer.OrderMode.SHUFFLE))
@@ -431,36 +424,30 @@ interface Album: Music {
}

menu.menuItem("Recommend").onClick {
FriendPickerDialog().apply {
onAccept = {
SyncService.send(
SyncService.Message.Recommendation(this@Album),
SyncService.Mode.OneOnOne(it)
)
}
}.show()
FriendPickerDialogStarter.newInstance(
SyncService.Message.Recommendation(this@Album.id),
"Send Recommendation"
).show(ctx)
}

menu.menuItem("Add to Collection").onClick {
PlaylistPickerDialogStarter.newInstance(this@Album).show()
PlaylistPickerDialog(this@Album).show(ctx)
}
}


fun loadCover(req: RequestManager): ReceiveChannel<RequestBuilder<Drawable>?>
= Library.instance.loadAlbumCover(req, id).map {
(
it ?: given(SearchApi.fullArtwork(this, true)) {
req.load(it)
(it ?: given(SearchApi.fullArtwork(this, true)) {
req.load(it)
// .thumbnail(req.load(remote?.thumbnailUrl).apply(Library.ARTWORK_OPTIONS))
}
)?.apply(Library.ARTWORK_OPTIONS
})?.apply(Library.ARTWORK_OPTIONS
.signature(ObjectKey("${id}full"))
)
}

fun loadThumbnail(req: RequestManager): ReceiveChannel<RequestBuilder<Drawable>?> = loadCover(req)

fun loadPalette(vararg views: View)
= loadPalette(id, views = views)
= loadPalette(id, views)
}
@@ -18,11 +18,14 @@ import com.loafofpiecrust.turntable.ui.RecyclerAdapter
import com.loafofpiecrust.turntable.ui.RecyclerGridItem
import com.loafofpiecrust.turntable.ui.RecyclerItem
import com.loafofpiecrust.turntable.ui.SectionedRecyclerAdapter
import com.loafofpiecrust.turntable.util.task
import com.loafofpiecrust.turntable.util.BG_POOL
import com.loafofpiecrust.turntable.util.cancelSafely
import com.simplecityapps.recyclerview_fastscroll.views.FastScrollRecyclerView
import kotlinx.coroutines.experimental.Job
import kotlinx.coroutines.experimental.android.UI
import kotlinx.coroutines.experimental.async
import kotlinx.coroutines.experimental.channels.consumeEach
import kotlinx.coroutines.experimental.withContext
import org.jetbrains.anko.*

// Album categories: All, ByArtist
@@ -81,24 +84,27 @@ class AlbumsAdapter(

if (holder.coverImage != null) {
holder.coverImage.imageResource = R.drawable.ic_default_album
val job = task(UI) {
val job = async(BG_POOL) {
album.loadThumbnail(Glide.with(holder.card.context)).consumeEach {
given(it) {
val req = given(it) {
it.apply(opts)
.transition(DrawableTransitionOptions().crossFade(200))
.listener(album.loadPalette(holder.card, holder.mainLine, holder.subLine))
.into(holder.coverImage)
} ?: run {
holder.coverImage.imageResource = R.drawable.ic_default_album
}

withContext(UI) {
req?.into(holder.coverImage) ?: run {
holder.coverImage.imageResource = R.drawable.ic_default_album
}
}
}
}
imageJobs.put(holder, job)?.cancel()
imageJobs.put(holder, job)?.cancelSafely()
}
}

override fun onViewRecycled(holder: RecyclerItem) {
imageJobs.remove(holder)?.cancel()
imageJobs.remove(holder)?.cancelSafely()
if (holder.coverImage != null) {
Glide.with(holder.card.context)
.clear(holder.coverImage)
@@ -153,7 +159,7 @@ class AlbumSectionAdapter(
.placeholder(R.drawable.ic_default_album)
.signature(ObjectKey(album.id.displayName))

imageJobs.put(holder, task(UI) {
imageJobs.put(holder, async(UI) {
album.loadThumbnail(Glide.with(holder.card.context)).consumeEach {
given(it) {
it.apply(opts)
@@ -164,7 +170,7 @@ class AlbumSectionAdapter(
holder.coverImage.imageResource = R.drawable.ic_default_album
}
}
})?.cancel()
})?.cancelSafely()
}
}

@@ -14,17 +14,24 @@ import com.loafofpiecrust.turntable.prefs.UserPrefs
import com.loafofpiecrust.turntable.service.Library
import com.loafofpiecrust.turntable.style.turntableStyle
import com.loafofpiecrust.turntable.ui.*
import com.loafofpiecrust.turntable.util.*
import com.loafofpiecrust.turntable.util.consumeEach
import com.loafofpiecrust.turntable.util.distinctSeq
import com.loafofpiecrust.turntable.util.replayOne
import fr.castorflex.android.circularprogressbar.CircularProgressDrawable
import kotlinx.android.parcel.Parcelize
import kotlinx.coroutines.experimental.channels.ConflatedBroadcastChannel
import kotlinx.coroutines.experimental.CoroutineStart
import kotlinx.coroutines.experimental.async
import kotlinx.coroutines.experimental.channels.BroadcastChannel
import kotlinx.coroutines.experimental.channels.ReceiveChannel
import kotlinx.coroutines.experimental.withContext
import kotlinx.coroutines.experimental.channels.consume
import kotlinx.coroutines.experimental.channels.consumeEach
import kotlinx.coroutines.experimental.launch
import org.jetbrains.anko.dimen
import org.jetbrains.anko.dip
import org.jetbrains.anko.frameLayout
import org.jetbrains.anko.padding
import org.jetbrains.anko.recyclerview.v7.recyclerView
import org.jetbrains.anko.support.v4.act
import org.jetbrains.anko.support.v4.ctx

class AlbumsFragment : BaseFragment() {
@@ -44,52 +51,66 @@ class AlbumsFragment : BaseFragment() {
}

// TODO: Customize parameters
@Arg lateinit var initialCategory: Category
@Arg lateinit var category: Category
@Arg(optional=true) var sortBy: SortBy = SortBy.NONE
@Arg(optional=true) var columnCount: Int = 0
@Arg(optional=true) var columnCount: Int = 3

// TODO: Make this cold by using a function returning the channel
// This would be to prevent any filtering, resolution, and merging from happening while the view is invisible!
// This also allows us to drop the channel entirely onDetach and make a new one in onAttach (if we want that??)
// private lateinit var albums: ReceiveChannel<List<Album>>
private lateinit var albums: () -> ReceiveChannel<List<Album>>
private val category by lazy { ConflatedBroadcastChannel(initialCategory) }
lateinit var albums: BroadcastChannel<List<Album>>
// private val category by lazy { ConflatedBroadcastChannel(initialCategory) }

companion object {
fun fromArtist(artist: Artist, mode: ArtistDetailsFragment.Mode): AlbumsFragment {
val cat = Category.ByArtist(artist.id, mode)
return AlbumsFragmentStarter.newInstance(cat).apply {
initialCategory = cat
albums = category.openSubscription().switchMap { cat ->
when (cat) {
is Category.All -> Library.instance.albums.openSubscription()
is Category.ByArtist -> {
// withContext(UI) {
// circleProgress.visibility = View.VISIBLE
// loadCircle.start()
// }

when (cat.mode) {
ArtistDetailsFragment.Mode.REMOTE -> produceTask(BG_POOL) {
artist.resolveAlbums(false)
}
ArtistDetailsFragment.Mode.LIBRARY_AND_REMOTE -> produceTask(BG_POOL) {
artist.resolveAlbums(true)
}
else -> Library.instance.albumsByArtist(cat.artist)
}
}
// is Category.Custom -> produceSingle(cat.albums)
}
}
fun all(): AlbumsFragment {
return AlbumsFragmentStarter.newInstance(Category.All())
}
fun fromChan(channel: ReceiveChannel<List<Album>>): AlbumsFragment {
return AlbumsFragmentStarter.newInstance(Category.All()).apply {
albums = channel.replayOne()//.broadcast(Channel.CONFLATED, start = CoroutineStart.DEFAULT)
}
}

// fun fromArtist(artist: ReceiveChannel<Artist>, mode: ReceiveChannel<ArtistDetailsFragment.Mode>): AlbumsFragment {
// val cat = Category.ByArtist(artist.id, mode)
// return AlbumsFragmentStarter.newInstance(cat).apply {
// albums = mode.switchMap { mode ->
// when (cat) {
// is Category.All -> Library.instance.albums.openSubscription()
// is Category.ByArtist -> {
//// withContext(UI) {
//// circleProgress.visibility = View.VISIBLE
//// loadCircle.start()
//// }
//
// when (cat.mode) {
// ArtistDetailsFragment.Mode.REMOTE -> produceTask(BG_POOL) {
// artist.resolveAlbums(false)
// }
// ArtistDetailsFragment.Mode.LIBRARY_AND_REMOTE -> produceTask(BG_POOL) {
// artist.resolveAlbums(true)
// }
// else -> Library.instance.albumsByArtist(cat.artist)
// }
// }
// // is Category.Custom -> produceSingle(cat.albums)
// }
// }
// }
// }
}

override fun onCreate() {
super.onCreate()
if (!::albums.isInitialized && category is Category.All) {
albums = Library.instance.albums
}
}

override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater?) = menu.run {
menuItem("Search", R.drawable.ic_search, showIcon=true).onClick {
MainActivity.replaceContent(
menuItem("Search", R.drawable.ic_search, showIcon=true).onClick(UI) {
act.replaceMainContent(
SearchFragmentStarter.newInstance(SearchFragment.Category.ALBUMS),
true
)
@@ -99,7 +120,7 @@ class AlbumsFragment : BaseFragment() {
group(0, true, true) {
val items = (1..4).map { idx ->
menuItem(idx.toString()).apply {
onClick { UserPrefs.albumGridColumns puts idx }
onClick(UI) { UserPrefs.albumGridColumns puts idx }
}
}

@@ -111,14 +132,14 @@ class AlbumsFragment : BaseFragment() {
}
}

override fun makeView(ui: ViewManager) = ui.frameLayout {
val cat = category.value
override fun ViewManager.createView() = frameLayout {
val cat = category
val grid = GridLayoutManager(context, 3)

val adapter = if (cat is Category.ByArtist) {
AlbumSectionAdapter { view, album ->
ctx.replaceMainContent(
DetailsFragmentStarter.newInstance(album.id),
act.replaceMainContent(
DetailsFragment.fromAlbum(album),
true,
view.transitionViews
)
@@ -127,8 +148,8 @@ class AlbumsFragment : BaseFragment() {
}
} else {
AlbumsAdapter(false) { view, album ->
ctx.replaceMainContent(
DetailsFragmentStarter.newInstance(album.id),
act.replaceMainContent(
DetailsFragment.fromAlbum(album),
true,
view.transitionViews
)
@@ -161,47 +182,49 @@ class AlbumsFragment : BaseFragment() {


recycler.apply {
id = R.id.gridRecycler
this.adapter = adapter

layoutManager = grid
padding = dimen(R.dimen.grid_gutter)

if (columnCount > 0) {
grid.spanCount = columnCount
} else {
} else launch(UI) {
UserPrefs.albumGridColumns.openSubscription()
.distinctSeq()
.consumeEach(UI) { grid.spanCount = it }
.consumeEach { grid.spanCount = it }
}

addItemDecoration(ItemOffsetDecoration(dimen(R.dimen.grid_gutter)))

val sub = albums.openSubscription()//.map {
// if (cat !is Category.All) {
// when (sortBy) {
// SortBy.NONE -> it
// SortBy.TITLE -> it.sortedWith(compareByIgnoreCase({ it.id.sortTitle }))
// SortBy.YEAR -> it.sortedByDescending {
// it.year ?: 0
// }.sortedBy { it.type.ordinal }
// }
// } else it
// }

if (adapter is AlbumsAdapter) {
adapter.subscribeData(sub)
} else if (adapter is AlbumSectionAdapter) {
sub.consumeEach(UI) {
adapter.replaceData(it)
}
}

albums.consumeEach(BG_POOL + jobs) {
withContext(UI) {
launch(UI, CoroutineStart.UNDISPATCHED) {
albums.consume {
while(receive().isEmpty()) {}
loadCircle.progressiveStop()
circleProgress.visibility = View.INVISIBLE
}

val data = if (cat !is Category.All) {
when (sortBy) {
SortBy.NONE -> it
SortBy.TITLE -> it.sortedWith(compareByIgnoreCase({ it.id.sortTitle }))
SortBy.YEAR -> it.sortedByDescending {
it.year ?: 0
}.sortedBy { it.type.ordinal }
}
} else it

if (adapter is AlbumsAdapter) {
adapter.updateData(data)
} else if (adapter is AlbumSectionAdapter) {
task(UI) { adapter.replaceData(data) }
}
}
/*.combineLatest(
App.instance.internetStatus.openSubscription()
)*/
}
}
}
@@ -2,10 +2,11 @@ package com.loafofpiecrust.turntable.album

import activitystarter.Arg
import android.arch.lifecycle.ViewModel
import android.graphics.Color.*
import android.graphics.Typeface.*
import android.graphics.Color.TRANSPARENT
import android.graphics.Typeface.BOLD
import android.support.constraint.ConstraintSet.PARENT_ID
import android.support.design.widget.AppBarLayout
import android.support.design.widget.CollapsingToolbarLayout.LayoutParams.*
import android.support.design.widget.CollapsingToolbarLayout.LayoutParams.COLLAPSE_MODE_PIN
import android.transition.*
import android.view.Gravity
import android.view.View
@@ -15,17 +16,13 @@ import android.widget.TextView
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
import com.loafofpiecrust.turntable.*
import com.loafofpiecrust.turntable.prefs.UserPrefs
import com.loafofpiecrust.turntable.service.library
import com.loafofpiecrust.turntable.browse.SearchApi
import com.loafofpiecrust.turntable.service.Library
import com.loafofpiecrust.turntable.song.SongsFragment
import com.loafofpiecrust.turntable.song.SongsFragmentStarter
import com.loafofpiecrust.turntable.style.detailsStyle
import com.loafofpiecrust.turntable.ui.BaseFragment
import com.loafofpiecrust.turntable.util.consumeEach
import com.loafofpiecrust.turntable.util.produceSingle
import com.loafofpiecrust.turntable.util.replayOne
import kotlinx.coroutines.experimental.channels.ConflatedBroadcastChannel
import kotlinx.coroutines.experimental.channels.ReceiveChannel
import kotlinx.coroutines.experimental.channels.*
import org.jetbrains.anko.*
import org.jetbrains.anko.appcompat.v7.toolbar
import org.jetbrains.anko.constraint.layout.ConstraintSetBuilder.Side.*
@@ -36,7 +33,6 @@ import org.jetbrains.anko.design.appBarLayout
import org.jetbrains.anko.design.collapsingToolbarLayout
import org.jetbrains.anko.design.coordinatorLayout
import org.jetbrains.anko.support.v4.ctx
import org.jetbrains.anko.support.v4.toast


class AlbumsViewModel: ViewModel() {
@@ -70,26 +66,32 @@ class DetailsFragment: BaseFragment() {
@Arg lateinit var albumId: AlbumId
@Arg(optional=true) var isPartial = false

lateinit var album: ReceiveChannel<Album>
lateinit var album: BroadcastChannel<Album>

companion object {
fun fromAlbum(album: Album, isPartial: Boolean = false): DetailsFragment {
return DetailsFragmentStarter.newInstance(album.id, isPartial).also {
it.album = produceSingle(album)
it.album = ConflatedBroadcastChannel(album)
}
}
}

override fun onCreate() {
super.onCreate()

val transDur = 200L
if (!::album.isInitialized) {
album = Library.instance.findAlbum(albumId).map {
it ?: SearchApi.find(albumId)!!
}.broadcast(-1)
}

val transDur = 400L

val trans = TransitionSet().setDuration(transDur)
.addTransition(ChangeBounds().setDuration(transDur))
// .addTransition(ChangeImageTransform().setDuration(1500))
.addTransition(ChangeTransform().setDuration(transDur))
.addTransition(ChangeClipBounds().setDuration(transDur))
val trans = TransitionSet()
.addTransition(ChangeBounds())
.addTransition(ChangeImageTransform())
.addTransition(ChangeTransform())
// .addTransition(ChangeClipBounds())

sharedElementEnterTransition = trans
sharedElementReturnTransition = trans
@@ -98,19 +100,18 @@ class DetailsFragment: BaseFragment() {
exitTransition = Fade().setDuration(1) // Just dissappear
// exitTransition = Fade().setDuration(transDur / 3)
// postponeEnterTransition()
// vm = MusicModelProviders.of(this, album.id.toString(), true).get(AlbumsViewModel::class.java)
// System.out.println("vm: ${vm.category.valueOrNull}")
}

override fun makeView(ui: ViewManager): View = ui.coordinatorLayout {
override fun ViewManager.createView(): View = coordinatorLayout {
backgroundColor = TRANSPARENT
// fitsSystemWindows = true

// val album = if (album.local !is Album.LocalDetails.Downloaded) {
// val existing = Library.instance.findAlbum(album)
// existing.blockingFirst().toNullable() ?: album
// } else album

val album = ctx.library.findCachedAlbum(albumId).replayOne()
// val album = ctx.library.findCachedAlbum(albumId).replayOne()


val coloredText = mutableListOf<TextView>()
@@ -120,9 +121,8 @@ class DetailsFragment: BaseFragment() {
backgroundColor = TRANSPARENT

lateinit var image: ImageView
collapsingToolbarLayout {
val collapser = collapsingToolbarLayout {
fitsSystemWindows = false
setContentScrimColor(UserPrefs.primaryColor.value)
collapsedTitleGravity = Gravity.BOTTOM
expandedTitleGravity = Gravity.BOTTOM
title = " "
@@ -177,25 +177,25 @@ class DetailsFragment: BaseFragment() {
val padBy = dimen(R.dimen.details_image_padding)
image {
connect(
TOP to TOP of this@constraintLayout,
START to START of this@constraintLayout,
BOTTOM to BOTTOM of this@constraintLayout,
END to END of this@constraintLayout
TOP to TOP of PARENT_ID,
START to START of PARENT_ID,
BOTTOM to BOTTOM of PARENT_ID,
END to END of PARENT_ID
)
width = matchConstraint
height = matchConstraint
dimensionRation = "H,1:1"
}
status {
connect(
BOTTOM to BOTTOM of this@constraintLayout margin padBy,
END to END of this@constraintLayout margin padBy
BOTTOM to BOTTOM of PARENT_ID margin padBy,
END to END of PARENT_ID margin padBy
)
}
year {
connect(
BOTTOM to BOTTOM of this@constraintLayout margin padBy,
START to START of this@constraintLayout margin padBy
BOTTOM to BOTTOM of PARENT_ID margin padBy,
START to START of PARENT_ID margin padBy
)
}
}
@@ -235,41 +235,45 @@ class DetailsFragment: BaseFragment() {
}

album.consumeEach(UI) { album ->
album?.loadCover(Glide.with(image))?.consumeEach(UI) {
album.loadCover(Glide.with(image))?.consumeEach {
it?.transition(DrawableTransitionOptions().crossFade(200))
?.listener(album.loadPalette(this@toolbar, mainLine, subLine))
?.listener(album.loadPalette(this@toolbar, mainLine, subLine, collapser))
?.into(image)
?: run { image.imageResource = R.drawable.ic_default_album }
}
}
}.lparams(height = matchParent)


if (album is RemoteAlbum) { // is remote album
// Option to mark the album for offline listening
// First, see if it's already marked
menuItem("Favorite", R.drawable.ic_turned_in_not, showIcon=true) {
ctx.library.findAlbum(album.id).consumeEach(UI) { existing ->
if (existing != null) {
icon = ctx.getDrawable(R.drawable.ic_turned_in)
setOnMenuItemClickListener {
// Remove remote album from library
ctx.library.removeRemoteAlbum(existing)
toast("Removed album to library")
true
}
} else {
icon = ctx.getDrawable(R.drawable.ic_turned_in_not)
setOnMenuItemClickListener {
ctx.library.addRemoteAlbum(album)
toast("Added album to library")
true
}
}
}
}
album.consumeEach(UI) {
menu.clear()
it.optionsMenu(ctx, menu)
}

// if (album is RemoteAlbum) { // is remote album
// // Option to mark the album for offline listening
// // First, see if it's already marked
// menuItem("Favorite", R.drawable.ic_turned_in_not, showIcon=true) {
// ctx.library.findAlbum(album.id).consumeEach(UI) { existing ->
// if (existing != null) {
// icon = ctx.getDrawable(R.drawable.ic_turned_in)
// setOnMenuItemClickListener {
// // Remove remote album from library
// ctx.library.removeRemoteAlbum(existing)
// toast("Removed album to library")
// true
// }
// } else {
// icon = ctx.getDrawable(R.drawable.ic_turned_in_not)
// setOnMenuItemClickListener {
// ctx.library.addRemoteAlbum(album)
// toast("Added album to library")
// true
// }
// }
// }
// }
// }

// album.optionsMenu(menu)

}.lparams {
@@ -283,12 +287,8 @@ class DetailsFragment: BaseFragment() {



verticalLayout {
id = R.id.container
val fragment = SongsFragmentStarter.newInstance(SongsFragment.Category.OnAlbum(albumId))
fragment(
childFragmentManager, fragment
)
frameLayout {
fragment(SongsFragment.onAlbum(albumId, album.openSubscription()))
}.lparams(width = matchParent, height = wrapContent) {
behavior = AppBarLayout.ScrollingViewBehavior()
}
@@ -1,24 +1,12 @@
package com.loafofpiecrust.turntable.artist

import com.loafofpiecrust.turntable.album.Album
import com.loafofpiecrust.turntable.artist.ArtistId
import com.loafofpiecrust.turntable.given
import com.loafofpiecrust.turntable.browse.SearchApi
import com.loafofpiecrust.turntable.dedupMerge
import com.loafofpiecrust.turntable.given
import kotlinx.coroutines.experimental.runBlocking


interface Artist {
val id: ArtistId
val albums: List<Album>
val startYear: Int?
val endYear: Int?


data class Member(
val name: String,
val id: String,
val active: Boolean
)
}

interface AlbumArtist {
val albums: List<Album>
val members: List<Artist.Member>? get() = null
@@ -27,32 +15,33 @@ interface AlbumArtist {
// class RemoteAlbumArtist: AlbumArtist {
// }

// loadArtwork(req):
//if (artworkUrl != null) {
// produce(BG_POOL) { send(req.load(artworkUrl).apply(Library.ARTWORK_OPTIONS)) }
//} else {
// Library.instance.loadArtistImage(req, id)
//}

class LocalArtist(
override val id: ArtistId,
override val albums: List<Album>
): Artist {
override val startYear get() = albums.minBy { it.year ?: Int.MAX_VALUE }?.year
override val endYear get() = albums.maxBy { it.year ?: 0 }?.year

private val remote get() = runBlocking { SearchApi.find(id) as RemoteArtist? }
override val biography: String? get() = remote?.biography
}

class RemoteArtist(
override val id: ArtistId,
val details: RemoteDetails,
val details: Artist.RemoteDetails,
override val startYear: Int? = null,
override val endYear: Int? = null
): Artist {
// Each API implements whether they have any of this info already
// or if it's all lazy or exists at all or what
interface RemoteDetails: Parcelable {
val albums: List<Album>
val biography: String
val members: List<Artist.Member>
}
override val albums get() = details.albums
override val biography get() = details.biography

override val albums: List<Album> by lazy {
details.albums
}
// Properties only obtained with details:
// - albums
// - members
@@ -74,6 +63,7 @@ class MergedArtist(val a: Artist, val b: Artist): Artist {
{ a, b -> a.mergeWith(b) }
)
}
override val biography get() = a.biography ?: b.biography
}

/*
@@ -1,16 +1,21 @@
package com.loafofpiecrust.turntable.artist

import android.content.Context
import android.graphics.drawable.Drawable
import android.os.Parcelable
import android.view.Menu
import android.view.View
import com.bumptech.glide.RequestBuilder
import com.bumptech.glide.RequestManager
import com.loafofpiecrust.turntable.*
import com.bumptech.glide.signature.ObjectKey
import com.loafofpiecrust.turntable.R
import com.loafofpiecrust.turntable.album.Album
import com.loafofpiecrust.turntable.album.AlbumId
import com.loafofpiecrust.turntable.album.loadPalette
import com.loafofpiecrust.turntable.browse.SearchApi
import com.loafofpiecrust.turntable.given
import com.loafofpiecrust.turntable.menuItem
import com.loafofpiecrust.turntable.onClick
import com.loafofpiecrust.turntable.player.MusicService
import com.loafofpiecrust.turntable.radio.RadioQueue
import com.loafofpiecrust.turntable.service.Library
@@ -19,17 +24,14 @@ import com.loafofpiecrust.turntable.song.Music
import com.loafofpiecrust.turntable.song.MusicId
import com.loafofpiecrust.turntable.song.SongId
import com.loafofpiecrust.turntable.song.withoutArticle
import com.loafofpiecrust.turntable.sync.FriendPickerDialog
import com.loafofpiecrust.turntable.ui.MainActivity
import com.loafofpiecrust.turntable.sync.FriendPickerDialogStarter
import com.loafofpiecrust.turntable.ui.replaceMainContent
import com.loafofpiecrust.turntable.util.BG_POOL
import com.mcxiaoke.koi.ext.toast
import com.loafofpiecrust.turntable.util.produceSingle
import kotlinx.android.parcel.Parcelize
import kotlinx.coroutines.experimental.channels.ReceiveChannel
import kotlinx.coroutines.experimental.channels.first
import kotlinx.coroutines.experimental.channels.produce
import org.jetbrains.anko.AnkoLogger
import org.jetbrains.anko.ctx
import kotlinx.coroutines.experimental.channels.map
import org.jetbrains.anko.toast
import java.util.*


@@ -38,7 +40,10 @@ data class ArtistId(
override val name: String,
val altName: String? = null,
var features: List<ArtistId> = listOf()
): MusicId, Parcelable {
): MusicId, Parcelable, Comparable<ArtistId> {
override fun compareTo(other: ArtistId): Int
= Library.ARTIST_COMPARATOR.compare(this, other)

private constructor(): this("")

override fun toString() = displayName
@@ -77,129 +82,202 @@ data class ArtistId(
} else ""
}

@Parcelize
data class Artist(
val id: ArtistId,
val remote: RemoteDetails?,
val albums: List<Album>,
val artworkUrl: String? = null,
val disambiguation: String? = null,
val startYear: Int? = null,
val endYear: Int? = null
) : Music {
data class Member(
val name: String,
val id: String,
val active: Boolean = true
)

/// For serialization libraries
constructor(): this(ArtistId(""), null, listOf())

interface RemoteDetails: Parcelable {
suspend fun resolveAlbums(): List<Album>
val description: String? get() = null
}

suspend fun resolveAlbums(includeLocals: Boolean = true): List<Album> {
// Find any local albums concurrently
val localAlbums = Library.instance.albumsByArtist(id).first()
// TODO: Have SearchApi.find(...) do caching so it's unified...
val cached = Library.instance.findCachedRemoteArtist(this@Artist).first()?.albums
?: SearchApi.find(id)?.albums

return when {
cached != null -> if (includeLocals) {
(cached + localAlbums)
} else {
cached.filter { a ->
localAlbums.find { b ->
a.id.displayName.equals(b.id.displayName, true)
} == null
}
}.dedupMerge(
{ a, b -> a.id.displayName.equals(b.id.displayName, true) && a.type == b.type },
{ a, b ->
if (a.year != null && a.year!! > 0 && (b.year == null || b.year!! <= 0)) {
// FIXME: Abstract album
// b.year = a.year
}
b
}
)
includeLocals -> localAlbums
else -> listOf()
}
}

companion object: AnkoLogger by AnkoLogger<Artist>() {
// @JvmField val CREATOR = PaperParcelArtist.CREATOR

fun justForSearch(name: String) = Artist(
ArtistId(name),
null,
listOf(),
null
)


// suspend fun findOnline(id: ArtistId): Artist? {
// val res = Http.get("https://musicbrainz.org/ws/2/artist/", params = mapOf(
// "fmt" to "json",
// "query" to "artist:\"${id.displayName}\"",
// "limit" to "2"
// )).gson.obj
//@Parcelize
//data class Artist(
// val id: ArtistId,
// val remote: RemoteDetails?,
// val albums: List<Album>,
// val artworkUrl: String? = null,
// val disambiguation: String? = null,
// val startYear: Int? = null,
// val endYear: Int? = null
//) : Music {
// data class Member(
// val name: String,
// val id: String,
// val active: Boolean = true
// )
//
// /// For serialization libraries
// constructor(): this(ArtistId(""), null, listOf())
//
// interface RemoteDetails: Parcelable {
// suspend fun resolveAlbums(): List<Album>
// val description: String? get() = null
// }
//
// suspend fun resolveAlbums(includeLocals: Boolean = true): List<Album> {
// // Find any local albums concurrently
// val localAlbums = Library.instance.albumsByArtist(id).first()
// // TODO: Have SearchApi.find(...) do caching so it's unified...
// val cached = Library.instance.findCachedRemoteArtist(this@Artist).first()?.albums
// ?: SearchApi.find(id)?.albums
//
// return when {
// cached != null -> if (includeLocals) {
// (cached + localAlbums)
// } else {
// cached.filter { a ->
// localAlbums.find { b ->
// a.id.displayName.equals(b.id.displayName, true)
// } == null
// }
// }.dedupMerge(
// { a, b -> a.id.displayName.equals(b.id.displayName, true) && a.type == b.type },
// { a, b ->
// if (a.year != null && a.year!! > 0 && (b.year == null || b.year!! <= 0)) {
// // FIXME: Abstract album
//// b.year = a.year
// }
// b
// }
// )
// includeLocals -> localAlbums
// else -> listOf()
// }
// }
//
// companion object: AnkoLogger by AnkoLogger<Artist>() {
//// @JvmField val CREATOR = PaperParcelArtist.CREATOR
//
// fun justForSearch(name: String) = Artist(
// ArtistId(name),
// null,
// listOf(),
// null
// )
//
//
// if (!res.has("artists")) return null // no dice
//// suspend fun findOnline(id: ArtistId): Artist? {
//// val res = Http.get("https://musicbrainz.org/ws/2/artist/", params = mapOf(
//// "fmt" to "json",
//// "query" to "artist:\"${id.displayName}\"",
//// "limit" to "2"
//// )).gson.obj
////
//// if (!res.has("artists")) return null // no dice
////
//// val artist = res["artists"][0].obj
//// val mbid = artist["id"].string
//// val name = artist["name"].string
////
//// return Artist(ArtistId(name), null, listOf(), null, mbid)
//// }
//
// val artist = res["artists"][0].obj
// val mbid = artist["id"].string
// val name = artist["name"].string
// suspend fun search(nameQuery: String): List<Artist>
// = SearchApi.searchArtists(nameQuery)
// }
//
// return Artist(ArtistId(name), null, listOf(), null, mbid)
// override val simpleName: String get() = id.displayName
//
//
// fun loadArtwork(req: RequestManager): ReceiveChannel<RequestBuilder<Drawable>?> =
// if (artworkUrl != null) {
// produce(BG_POOL) { send(req.load(artworkUrl).apply(Library.ARTWORK_OPTIONS)) }
// } else {
// Library.instance.loadArtistImage(req, id)
// }
//
// fun loadPalette(vararg views: View)
// = loadPalette(id, views)
//
// fun minimize(): Artist = if (albums.isNotEmpty()) {
// copy(albums = listOf())
// } else this
//
//
//
// override fun optionsMenu(menu: Menu) = with(menu) {
// val ctx = MainActivity.latest.ctx
// menuItem("Similar Artists", R.drawable.ic_people, showIcon=false).onClick {
// ctx.replaceMainContent(
// RelatedArtistsFragmentStarter.newInstance(this@Artist),
// true
// )
// }
//
// menuItem("Recommend", showIcon=false).onClick {
// FriendPickerDialog().apply {
// onAccept = {
// SyncService.send(
// SyncService.Message.Recommendation(minimize()),
// SyncService.Mode.OneOnOne(it)
// )
// }
// }.show()
// }
//
// // TODO: Sync with radios...
// // TODO: Sync with any type of queue!
// menuItem("Start Radio", showIcon=false).onClick(BG_POOL) {
// MusicService.enact(SyncService.Message.Pause(), false)
//
// val radio = RadioQueue.fromSeed(listOf(this@Artist))
// if (radio != null) {
// MusicService.enact(SyncService.Message.ReplaceQueue(radio))
// MusicService.enact(SyncService.Message.Play())
// } else {
// ctx.toast("Not enough data on '${id.displayName}'")
// }
// }
//
// menuItem("Biography", showIcon=false).onClick(BG_POOL) {
// ctx.replaceMainContent(BiographyFragmentStarter.newInstance(this@Artist.minimize()))
// }
// }
//}

suspend fun search(nameQuery: String): List<Artist>
= SearchApi.searchArtists(nameQuery)
}

override val simpleName: String get() = id.displayName
interface Artist: Music {
val id: ArtistId
val albums: List<Album>
val startYear: Int?
val endYear: Int?
val biography: String?

override val simpleName get() = id.displayName

data class Member(
val name: String,
val id: String,
val active: Boolean
)
// Each API implements whether they have any of this info already
// or if it's all lazy or exists at all or what
interface RemoteDetails: Parcelable {
val albums: List<Album>
val biography: String
// val members: List<Artist.Member>
}


fun loadThumbnail(req: RequestManager): ReceiveChannel<RequestBuilder<Drawable>?> = loadArtwork(req)
fun loadArtwork(req: RequestManager): ReceiveChannel<RequestBuilder<Drawable>?> =
if (artworkUrl != null) {
produce(BG_POOL) { send(req.load(artworkUrl).apply(Library.ARTWORK_OPTIONS)) }
} else {
Library.instance.loadArtistImage(req, id)
Library.instance.loadArtistImage(req, id).map {
(it ?: given(SearchApi.fullArtwork(this, true)) {
req.load(it)
})?.apply(Library.ARTWORK_OPTIONS
.signature(ObjectKey("${id}full"))
)
}

fun loadPalette(vararg views: View)
= loadPalette(id, views)

fun minimize(): Artist = if (albums.isNotEmpty()) {
copy(albums = listOf())
} else this



override fun optionsMenu(menu: Menu) = with(menu) {
val ctx = MainActivity.latest.ctx
override fun optionsMenu(ctx: Context, menu: Menu) = with(menu) {
menuItem("Similar Artists", R.drawable.ic_people, showIcon=false).onClick {
ctx.replaceMainContent(
RelatedArtistsFragmentStarter.newInstance(this@Artist),
RelatedArtistsFragmentStarter.newInstance(this@Artist.id),
true
)
}

menuItem("Recommend", showIcon=false).onClick {
FriendPickerDialog().apply {
onAccept = {
SyncService.send(
SyncService.Message.Recommendation(minimize()),
SyncService.Mode.OneOnOne(it)
)
}
}.show()
FriendPickerDialogStarter.newInstance(
SyncService.Message.Recommendation(this@Artist.id),
"Send Recommendation"
).show(ctx)
}

// TODO: Sync with radios...
@@ -209,15 +287,15 @@ data class Artist(

val radio = RadioQueue.fromSeed(listOf(this@Artist))
if (radio != null) {
MusicService.enact(SyncService.Message.ReplaceQueue(radio))
// MusicService.enact(SyncService.Message.ReplaceQueue(radio))
MusicService.enact(SyncService.Message.Play())
} else {
ctx.toast("Not enough data on '${id.displayName}'")
}
}

menuItem("Biography", showIcon=false).onClick(BG_POOL) {
ctx.replaceMainContent(BiographyFragmentStarter.newInstance(this@Artist.minimize()))
BiographyFragment.fromChan(produceSingle(this@Artist)).show(ctx)
}
}
}
}
@@ -10,30 +10,32 @@ import android.view.Gravity
import android.view.View
import android.view.ViewManager
import android.widget.ImageView
import com.loafofpiecrust.turntable.*
import com.bumptech.glide.Glide
import com.loafofpiecrust.turntable.R
import com.loafofpiecrust.turntable.album.AlbumsFragment
import com.loafofpiecrust.turntable.album.AlbumsFragmentStarter
import com.loafofpiecrust.turntable.browse.SearchApi
import com.loafofpiecrust.turntable.collapsingToolbarlparams
import com.loafofpiecrust.turntable.generateChildrenIds
import com.loafofpiecrust.turntable.given
import com.loafofpiecrust.turntable.service.Library
import com.loafofpiecrust.turntable.style.standardStyle
import com.loafofpiecrust.turntable.ui.BaseFragment
import com.loafofpiecrust.turntable.util.produceSingle
import com.loafofpiecrust.turntable.util.task
import com.mcxiaoke.koi.ext.onClick
import com.loafofpiecrust.turntable.util.*
import kotlinx.coroutines.experimental.channels.BroadcastChannel
import kotlinx.coroutines.experimental.channels.ConflatedBroadcastChannel
import kotlinx.coroutines.experimental.channels.ReceiveChannel
import kotlinx.coroutines.experimental.channels.consumeEach
import kotlinx.coroutines.experimental.channels.broadcast
import kotlinx.coroutines.experimental.channels.map
import org.jetbrains.anko.*
import org.jetbrains.anko.appcompat.v7.toolbar
import org.jetbrains.anko.constraint.layout.ConstraintSetBuilder.Side.*
import org.jetbrains.anko.constraint.layout.applyConstraintSet
import org.jetbrains.anko.constraint.layout.constraintLayout
import org.jetbrains.anko.constraint.layout.matchConstraint
import org.jetbrains.anko.design.appBarLayout
import org.jetbrains.anko.design.collapsingToolbarLayout
import org.jetbrains.anko.design.coordinatorLayout
import org.jetbrains.anko.design.themedAppBarLayout
import org.jetbrains.anko.support.v4.selector
import org.jetbrains.anko.support.v4.ctx

/**
* We want to be able to open Artist details with either:
@@ -42,70 +44,75 @@ import org.jetbrains.anko.support.v4.selector
* 3. artist id (from saved state)
*/
class ArtistDetailsFragment: BaseFragment() {
enum class Mode {
LIBRARY, REMOTE, LIBRARY_AND_REMOTE
}

@Arg lateinit var artistId: ArtistId
private lateinit var artist: ReceiveChannel<Artist>
// Procedural creation:
// init: artistId is known
// create channel of found artist from id (maybe local or remote)
// onResume: start receiving events on the UI (reopen channels)
// onPause: stop receiving events to the UI (cancel channels)
// onResume: resume receiving events on the UI (reopen channels)
// onDestroy: get rid of any trace of channels, we don't care anymore

// Restoration:
// onCreate: load artistId from bundle and find it via a channel
private lateinit var artist: BroadcastChannel<Artist>

// @Arg lateinit var artistId: ArtistId
@Arg(optional = true) var initialMode = Mode.LIBRARY

enum class Mode {
LIBRARY, REMOTE, LIBRARY_AND_REMOTE
}

private val currentMode by lazy { ConflatedBroadcastChannel(initialMode) }
private lateinit var albums: AlbumsFragment

companion object {
fun fromId(id: ArtistId): ArtistDetailsFragment? {
val frag = ArtistDetailsFragmentStarter.newInstance(id).apply {
setupArtist(id)
}
val frag = ArtistDetailsFragmentStarter.newInstance(id)
return frag
}

fun fromArtist(artist: Artist, mode: Mode = Mode.LIBRARY): ArtistDetailsFragment {
return ArtistDetailsFragmentStarter.newInstance(artist.id, mode).also {
it.artist = produceSingle(artist)
return ArtistDetailsFragment().also {
it.initialMode = mode
it.artistId = artist.id
it.artist = ConflatedBroadcastChannel(artist)
}
}
}

// TODO: Generalize this some more
private fun setupArtist(id: ArtistId) {
artist = Library.instance.findArtist(id).map {
it ?: SearchApi.find(id)!!
}
}

override fun onCreate() {
super.onCreate()

val transDur = 250L
onApi(21) {
val trans = TransitionSet().setDuration(transDur)
.addTransition(ChangeBounds().setDuration(transDur))
// .addTransition(ChangeImageTransform().setDuration(1500))
.addTransition(ChangeTransform().setDuration(transDur))
.addTransition(ChangeClipBounds().setDuration(transDur))

sharedElementEnterTransition = trans
sharedElementReturnTransition = trans
// TODO: Generalize this some more
if (!::artist.isInitialized) {
artist = Library.instance.findArtist(artistId).map {
it ?: SearchApi.find(artistId)!!
}.broadcast()
}

enterTransition = Fade().setDuration(transDur)
val trans = TransitionSet()
.addTransition(ChangeBounds())
.addTransition(ChangeTransform())
.addTransition(ChangeClipBounds())

sharedElementEnterTransition = trans
sharedElementReturnTransition = trans

enterTransition = Fade()
// exitTransition = Fade().setDuration(transDur / 3)
}

override fun makeView(ui: ViewManager): View = ui.coordinatorLayout {
override fun ViewManager.createView(): View = coordinatorLayout {
// val artist = if (artist != null) {
// produceSingle(artist!!)
// } else {
// ctx.library.findArtist(artistId)
// }

lateinit var tabs: TabLayout
themedAppBarLayout(R.style.AppTheme_AppBarOverlay) {
appBarLayout {
backgroundColor = Color.TRANSPARENT

lateinit var image: ImageView
@@ -144,24 +151,18 @@ class ArtistDetailsFragment: BaseFragment() {
Mode.LIBRARY_AND_REMOTE to "All"
)

task(UI) {
currentMode.consumeEach { mode ->
text = getString(
R.string.artist_content_source,
choices.find { it.first == mode }!!.second
)
// albums.category.send(
// AlbumsFragment.Category.ByArtist(artist.copy(albums = listOf()), mode)
// )
}
currentMode.consumeEach(UI) { mode ->
text = getString(
R.string.artist_content_source,
choices.find { it.first == mode }!!.second
)
}

onClick {
selector("Choose Display Mode", choices.map { it.second }) { dialog, idx ->
val (choice, _) = choices[idx]
currentMode puts choice
}
}
// onClick(UI) {
// it!!.context.selector("Choose Display Mode", choices) { dialog, choice ->
// currentMode puts choice
// }
// }
}

generateChildrenIds()
@@ -206,30 +207,71 @@ class ArtistDetailsFragment: BaseFragment() {
standardStyle(UI)
title = artistId.displayName
transitionName = artistId.nameTransition
// artist.optionsMenu(menu)
}.lparams {
artist.consumeEach(UI) {
menu.clear()
it.optionsMenu(ctx, menu)
}
}.lparams(width = matchParent) {
scrollFlags = AppBarLayout.LayoutParams.SCROLL_FLAG_SCROLL and AppBarLayout.LayoutParams.SCROLL_FLAG_EXIT_UNTIL_COLLAPSED
}

// artist.loadArtwork(Glide.with(image)).consumeEach(UI) {
// it?.listener(artist.loadPalette(toolbar))
// ?.into(image) ?: run {
// image.imageResource = R.drawable.ic_default_album
// }
// }
artist.openSubscription().switchMap { artist ->
artist.loadArtwork(Glide.with(image)).map {
it?.listener(artist.loadPalette(toolbar))
}
}.consumeEach(UI) {
it?.into(image) ?: run {
image.imageResource = R.drawable.ic_default_album
}
}


}.lparams(width = matchParent, height = wrapContent)


frameLayout {
id = View.generateViewId()
fragment(fragmentManager, AlbumsFragmentStarter.newInstance(
// fragment(fragmentManager, AlbumsFragment.fromArtist(artist, currentMode.value) Starter.newInstance(
// AlbumsFragment.Category.ByArtist(artistId, currentMode.value),
// AlbumsFragment.SortBy.YEAR,
// 3
// ).also {
// it.albums =
// })
fragment(AlbumsFragmentStarter.newInstance(
AlbumsFragment.Category.ByArtist(artistId, currentMode.value),
AlbumsFragment.SortBy.YEAR,
3
).also {
it.albums =
).apply {
albums = artist.openSubscription()
.combineLatest(currentMode.openSubscription())
.switchMap(BG_POOL) { (artist, mode) ->
val isLocal = artist is LocalArtist
when (mode) {
Mode.LIBRARY -> if (isLocal) {
produceSingle(artist)
} else Library.instance.findArtist(artist.id)
Mode.LIBRARY_AND_REMOTE -> if (isLocal) {
produceSingle(given(SearchApi.find(artist.id)) {
MergedArtist(artist, it)
} ?: artist)
} else {
Library.instance.findArtist(artist.id).map {
if (it != null) {
MergedArtist(artist, it)
} else artist
}
}
Mode.REMOTE -> if (isLocal) {
produceSingle(SearchApi.find(artist.id) ?: artist)
} else {
produceSingle(artist)
}
}
}.map { it!!.albums }
.replayOne()
})

}.lparams(width = matchParent) {
behavior = AppBarLayout.ScrollingViewBehavior()
}
@@ -7,37 +7,64 @@ import android.view.*
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
import com.bumptech.glide.request.RequestOptions
import com.evernote.android.state.State
import com.loafofpiecrust.turntable.*
import com.loafofpiecrust.turntable.album.loadPalette
import com.loafofpiecrust.turntable.browse.Spotify
import com.loafofpiecrust.turntable.prefs.UserPrefs
import com.loafofpiecrust.turntable.service.Library
import com.loafofpiecrust.turntable.style.turntableStyle
import com.loafofpiecrust.turntable.ui.*
import com.loafofpiecrust.turntable.util.produceTask
import com.loafofpiecrust.turntable.util.cancelSafely
import com.loafofpiecrust.turntable.util.task
import com.simplecityapps.recyclerview_fastscroll.views.FastScrollRecyclerView
import kotlinx.android.parcel.Parcelize
import kotlinx.coroutines.experimental.Job
import kotlinx.coroutines.experimental.android.UI
import kotlinx.coroutines.experimental.channels.consumeEach
import kotlinx.coroutines.experimental.async
import kotlinx.coroutines.experimental.channels.*
import org.jetbrains.anko.*
import org.jetbrains.anko.recyclerview.v7.recyclerView
import org.jetbrains.anko.support.v4.ctx
import org.jetbrains.anko.support.v4.act

class ArtistsFragment : BaseFragment() {
sealed class Category: Parcelable {
@Parcelize class All: Category()
@Parcelize class Custom(val artists: List<Artist>): Category()
@Parcelize data class RelatedTo(val id: ArtistId): Category()
// @Parcelize class Custom(val artists: List<Artist>): Category()
}

@Arg lateinit var category: Category
@Arg(optional=true) var columnCount: Int? = null
@State var listState: Parcelable? = null

lateinit var artists: BroadcastChannel<List<Artist>>

// TODO: Use Category!!
companion object {
fun relatedTo(artist: Artist): ArtistsFragment {
return ArtistsFragmentStarter.newInstance(Category.RelatedTo(artist.id)).apply {
artists = broadcast(capacity = Channel.CONFLATED) { send(Spotify.similarTo(artist.id)) }
}
}
fun fromChan(channel: ReceiveChannel<List<Artist>>): ArtistsFragment {
return ArtistsFragmentStarter.newInstance(Category.All()).apply {
artists = channel.broadcast(Channel.CONFLATED)
}
}
}

override fun onCreate() {
super.onCreate()
if (!::artists.isInitialized) {
artists = Library.instance.artists
}
}


override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater?) {
menu.menuItem("Search", R.drawable.ic_search, showIcon = true) {
onClick {
MainActivity.replaceContent(
act.replaceMainContent(
SearchFragmentStarter.newInstance(SearchFragment.Category.ARTISTS),
true
)
@@ -62,36 +89,33 @@ class ArtistsFragment : BaseFragment() {
}
}

override fun makeView(ui: ViewManager): View = with(ui) {
override fun ViewManager.createView(): View = with(this) {
val cat = category

val recycler = if (cat is Category.All) {
fastScrollRecycler {
turntableStyle(jobs)
turntableStyle(UI)
}
} else {
recyclerView {
lparams(height=matchParent, width=matchParent)
turntableStyle(jobs)
lparams(height = matchParent, width = matchParent)
turntableStyle(UI)
}
}

recycler.apply {
this.adapter = ArtistsAdapter { holder, artists, idx ->
adapter = ArtistsAdapter { holder, artists, idx ->
listState = layoutManager.onSaveInstanceState()
// smoothly transition the cover image!
ctx.replaceMainContent(
holder.itemView.context.replaceMainContent(
ArtistDetailsFragment.fromArtist(artists[idx]), true,
holder.transitionViews
)
}.apply {
task(UI) {
when (cat) {
is Category.All -> Library.instance.artists.openSubscription()
is Category.Custom -> produceTask { cat.artists }
}.consumeEach {
updateData(it)
}
}
subscribeData(artists.openSubscription())
// artists.consumeEach(UI) {
// updateData(it)
// }
}

layoutManager = GridLayoutManager(context, 3).also { grid ->
@@ -101,7 +125,7 @@ class ArtistsFragment : BaseFragment() {
UserPrefs.artistGridColumns.consumeEach {
(adapter as ArtistsAdapter).apply {
gridSize = it
notifyDataSetChanged()
// notifyDataSetChanged()
}
grid.spanCount = it
}
@@ -119,7 +143,7 @@ class ArtistsAdapter(
private val listener: (RecyclerItem, List<Artist>, Int) -> Unit
) : RecyclerAdapter<Artist, RecyclerItem>(
itemsSame = { a, b, aIdx, bIdx -> a.id == b.id },
contentsSame = { a, b, aIdx, bIdx -> a.id == b.id && a.artworkUrl == b.artworkUrl && a.remote == b.remote }
contentsSame = { a, b, aIdx, bIdx -> a.id == b.id /*&& a.artworkUrl == b.artworkUrl && a.remote == b.remote*/ }
),
FastScrollRecyclerView.SectionedAdapter
{
@@ -141,7 +165,7 @@ class ArtistsAdapter(
holder.header.transitionName = item.id.nameTransition

holder.mainLine.text = item.id.displayName
given(item.disambiguation) {
given(item.id.altName) {
holder.subLine.text = it
}

@@ -152,21 +176,19 @@ class ArtistsAdapter(
holder.card.backgroundColor = UserPrefs.primaryColor.value

if (holder.coverImage != null) {
val job = task(UI) {
item.loadArtwork(Glide.with(holder.card.context)).consumeEach {
given(it) {
val job = async(UI) {
item.loadThumbnail(Glide.with(holder.card.context)).consumeEach {
if (it != null) {
it.apply(RequestOptions().placeholder(R.drawable.ic_default_album))
.listener(loadPalette(item.id, arrayOf(holder.card)))
.listener(item.loadPalette(holder.card, holder.mainLine, holder.subLine))
.transition(DrawableTransitionOptions().crossFade(200))
// withContext(UI) {
.into(holder.coverImage)
// }
} ?: run {
} else {
holder.coverImage.imageResource = R.drawable.ic_default_album
}
}
}
imageJobs.put(holder, job)?.cancel()
imageJobs.put(holder, job)?.cancelSafely()
}

}
@@ -1,38 +1,46 @@
package com.loafofpiecrust.turntable.artist

import activitystarter.Arg
import android.view.ViewManager
import android.widget.TextView
import com.loafofpiecrust.turntable.R
import com.loafofpiecrust.turntable.browse.SearchApi
import com.loafofpiecrust.turntable.provided
import com.loafofpiecrust.turntable.style.standardStyle
import com.loafofpiecrust.turntable.ui.BaseFragment
import kotlinx.coroutines.experimental.runBlocking
import com.loafofpiecrust.turntable.ui.BaseDialogFragment
import com.loafofpiecrust.turntable.util.consumeEach
import kotlinx.coroutines.experimental.channels.ReceiveChannel
import org.jetbrains.anko.*
import org.jetbrains.anko.appcompat.v7.toolbar

class BiographyFragment: BaseFragment() {
@Arg lateinit var artist: Artist
class BiographyFragment: BaseDialogFragment() {
// @Arg(optional = true) lateinit var artistId: ArtistId
lateinit var artist: ReceiveChannel<Artist>

override fun makeView(ui: ViewManager) = ui.verticalLayout {
fitsSystemWindows = true

val remote = artist.remote.provided {
it?.description != null
} ?: runBlocking { SearchApi.find(artist.id)?.remote }
companion object {
fun fromChan(channel: ReceiveChannel<Artist>): BiographyFragment {
return BiographyFragment().apply {
artist = channel
}
}
}

override fun ViewManager.createView() = verticalLayout {
fitsSystemWindows = true

// TODO: Add full-res artist image, as not-cropped as possible.
// TODO: Multiple artist images?
toolbar {
standardStyle(UI)
title = artist.id.displayName
val toolbar = toolbar {
standardStyle(UI, true)
setNavigationOnClickListener { dismiss() }
}

lateinit var bioText: TextView
scrollView {
textView(remote?.description)
}.lparams(matchParent, matchParent) {
padding = dimen(R.dimen.text_content_margin)
bioText = textView()
}.lparams(matchParent, matchParent)

artist.consumeEach(UI) { artist ->
toolbar.title = artist.id.displayName
bioText.text = artist.biography
}
}
}
@@ -4,95 +4,71 @@ import activitystarter.Arg
import android.support.v7.widget.GridLayoutManager
import android.view.View
import android.view.ViewManager
import com.github.salomonbrys.kotson.*
import com.loafofpiecrust.turntable.BuildConfig
import com.loafofpiecrust.turntable.browse.MusicBrainz
import com.loafofpiecrust.turntable.browse.Spotify
import com.loafofpiecrust.turntable.given
import com.loafofpiecrust.turntable.provided
import com.loafofpiecrust.turntable.tryOr
import com.loafofpiecrust.turntable.ui.BaseFragment
import com.loafofpiecrust.turntable.ui.replaceMainContent
import com.loafofpiecrust.turntable.util.Http
import com.loafofpiecrust.turntable.util.gson
import com.loafofpiecrust.turntable.util.success
import com.loafofpiecrust.turntable.util.task
import org.jetbrains.anko.frameLayout
import com.loafofpiecrust.turntable.util.BG_POOL
import com.loafofpiecrust.turntable.util.produceTask
import org.jetbrains.anko.recyclerview.v7.recyclerView
import org.jetbrains.anko.support.v4.ctx

class RelatedArtistsFragment: BaseFragment() {
@Arg lateinit var artist: Artist
@Arg lateinit var artistId: ArtistId
lateinit var gridAdapter: ArtistsAdapter

override fun makeView(ui: ViewManager): View {
// ActivityStarter.fill(this)
return ui.frameLayout {
recyclerView {
// TODO: dynamic grid size
layoutManager = GridLayoutManager(context, 3)
adapter = ArtistsAdapter { view, artists, pos ->
// smoothly transition the cover image!
val artist = artists[pos]
ctx.replaceMainContent(
ArtistDetailsFragment.fromArtist(artist, ArtistDetailsFragment.Mode.LIBRARY_AND_REMOTE),
true,
view.transitionViews
)
}.apply {
// TODO: Have this only load when this tab is entered.
// val nameQuery = URLEncoder.encode(artist.id, "UTF-8")
// println("related artists for '$nameQuery'")
task {
// Thread.sleep(1000)
tryOr(0) {
Spotify.similarTo(artist.id).also {
if (it.isNotEmpty()) {
return@task it
}
}
}
override fun ViewManager.createView(): View = recyclerView {
// TODO: dynamic grid size
layoutManager = GridLayoutManager(context, 3)
adapter = ArtistsAdapter { view, artists, pos ->
// smoothly transition the cover image!
val artist = artists[pos]
ctx.replaceMainContent(
ArtistDetailsFragment.fromArtist(artist, ArtistDetailsFragment.Mode.LIBRARY_AND_REMOTE),
true,
view.transitionViews
)
}.apply {
subscribeData(produceTask(BG_POOL + jobs) { Spotify.similarTo(artistId) })
// task {
// Spotify.similarTo(artistId)

// Load the related artists from last.fm
// Load the related artists from last.fm

val remote = artist.remote
val query = if (remote is MusicBrainz.ArtistDetails && artist.id.displayName.length <= 5) {
"mbid" to remote.id
} else {
"artist" to artist.id.name
}
val res = Http.get(
"https://ws.audioscrobbler.com/2.0/",
params = mapOf(
"api_key" to BuildConfig.LASTFM_API_KEY,
"method" to "artist.getsimilar",
"format" to "json",
"autocorrect" to "1",
query
)
).gson.obj

res["similarartists"]["artist"].array.map { it.obj }.map {
val img = it["image"][2]["#text"].string
val mbid = it["mbid"].nullString
val name = it["name"].string
// Use Last.FM data directly if the id is real short,
// because the mapping to MusicBrainz is quite likely wrong
Artist(
ArtistId(name),
given(mbid.provided(name.length > 3)) {
MusicBrainz.ArtistDetails(it)
},
listOf(),
img
)
}
}.success(UI) {
updateData(it)
}
Unit
}
}
// val remote = artist.remote
// val query = if (remote is MusicBrainz.ArtistDetails && artist.id.displayName.length <= 5) {
// "mbid" to remote.id
// } else {
// "artist" to artist.id.name
// }
// val res = Http.get(
// "https://ws.audioscrobbler.com/2.0/",
// params = mapOf(
// "api_key" to BuildConfig.LASTFM_API_KEY,
// "method" to "artist.getsimilar",
// "format" to "json",
// "autocorrect" to "1",
// query
// )
// ).gson.obj
//
// res["similarartists"]["artist"].array.map { it.obj }.map {
// val img = it["image"][2]["#text"].string
// val mbid = it["mbid"].nullString
// val name = it["name"].string
// // Use Last.FM data directly if the id is real short,
// // because the mapping to MusicBrainz is quite likely wrong
// Artist(
// ArtistId(name),
// given(mbid.provided(name.length > 3)) {
// MusicBrainz.ArtistDetails(it)
// },
// listOf(),
// img
// )
// }
// }.success(UI) {
// updateData(it)
// }
}
}
}