mirror of
https://github.com/clangen/musikcube.git
synced 2025-01-02 11:58:27 +00:00
Fleshed out GaplessHeaderService (and DAO/DB friends). Tracks downloaded
via realtime transcoder are now patched up after-the-fact, so subsequent plays are gapless. Also added a new, custom version of AndroidVideoCache that supports receiving response headers.
This commit is contained in:
parent
33d3c1544c
commit
19db8456c8
@ -66,7 +66,7 @@ dependencies {
|
||||
|
||||
implementation(name:'android-taskrunner-0.5', ext:'aar')
|
||||
|
||||
implementation(name:'videocache-2.8.0-pre', ext:'aar')
|
||||
implementation(name:'videocache-2.8.0-clangen-3', ext:'aar')
|
||||
implementation 'org.slf4j:slf4j-android:1.7.21'
|
||||
|
||||
implementation "android.arch.persistence.room:runtime:1.0.0"
|
||||
|
BIN
src/musikdroid/app/libs/videocache-2.8.0-clangen-3.aar
Normal file
BIN
src/musikdroid/app/libs/videocache-2.8.0-clangen-3.aar
Normal file
Binary file not shown.
Binary file not shown.
@ -6,10 +6,13 @@ import io.casey.musikcube.remote.injection.AppComponent
|
||||
import io.casey.musikcube.remote.injection.AppModule
|
||||
import io.casey.musikcube.remote.injection.DaggerAppComponent
|
||||
import io.casey.musikcube.remote.injection.ServiceModule
|
||||
import io.casey.musikcube.remote.ui.shared.util.NetworkUtil
|
||||
import io.casey.musikcube.remote.service.gapless.GaplessHeaderService
|
||||
import io.fabric.sdk.android.Fabric
|
||||
import javax.inject.Inject
|
||||
|
||||
class Application : android.app.Application() {
|
||||
@Inject lateinit var gaplessService: GaplessHeaderService
|
||||
|
||||
override fun onCreate() {
|
||||
instance = this
|
||||
|
||||
@ -20,14 +23,16 @@ class Application : android.app.Application() {
|
||||
.serviceModule(ServiceModule())
|
||||
.build()
|
||||
|
||||
appComponent.inject(this)
|
||||
|
||||
gaplessService.schedule()
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
Stetho.initializeWithDefaults(this)
|
||||
}
|
||||
else {
|
||||
Fabric.with(this, Crashlytics())
|
||||
}
|
||||
|
||||
NetworkUtil.init()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
@ -2,6 +2,7 @@ package io.casey.musikcube.remote.injection
|
||||
|
||||
import android.content.Context
|
||||
import dagger.Component
|
||||
import io.casey.musikcube.remote.Application
|
||||
import io.casey.musikcube.remote.service.gapless.GaplessHeaderService
|
||||
import io.casey.musikcube.remote.service.gapless.db.GaplessDb
|
||||
import io.casey.musikcube.remote.service.playback.impl.streaming.StreamProxy
|
||||
@ -13,6 +14,8 @@ import io.casey.musikcube.remote.ui.settings.model.ConnectionsDb
|
||||
@ApplicationScope
|
||||
@Component(modules = arrayOf(AppModule::class, DataModule::class, ServiceModule::class))
|
||||
interface AppComponent {
|
||||
fun inject(app: Application)
|
||||
|
||||
fun webSocketService(): WebSocketService /* via ServiceModule */
|
||||
fun gaplessHeaderService(): GaplessHeaderService /* via ServiceModule */
|
||||
fun streamProxy(): StreamProxy /* via ServiceModule */
|
||||
|
@ -3,6 +3,7 @@ package io.casey.musikcube.remote.injection
|
||||
import dagger.Component
|
||||
import io.casey.musikcube.remote.service.gapless.GaplessHeaderService
|
||||
import io.casey.musikcube.remote.service.playback.impl.remote.RemotePlaybackService
|
||||
import io.casey.musikcube.remote.service.playback.impl.streaming.StreamProxy
|
||||
import io.casey.musikcube.remote.service.playback.impl.streaming.StreamingPlaybackService
|
||||
|
||||
@ServiceScope
|
||||
@ -11,4 +12,5 @@ interface ServiceComponent {
|
||||
fun inject(service: StreamingPlaybackService)
|
||||
fun inject(service: RemotePlaybackService)
|
||||
fun inject(service: GaplessHeaderService)
|
||||
fun inject(service: StreamProxy)
|
||||
}
|
@ -1,17 +1,124 @@
|
||||
package io.casey.musikcube.remote.service.gapless
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.os.Message
|
||||
import android.util.Base64
|
||||
import io.casey.musikcube.remote.Application
|
||||
import io.casey.musikcube.remote.injection.DaggerServiceComponent
|
||||
import io.casey.musikcube.remote.injection.DataModule
|
||||
import io.casey.musikcube.remote.service.gapless.db.GaplessDb
|
||||
import io.casey.musikcube.remote.service.gapless.db.GaplessTrack
|
||||
import io.casey.musikcube.remote.service.playback.impl.streaming.StreamProxy
|
||||
import io.casey.musikcube.remote.ui.settings.constants.Prefs
|
||||
import io.casey.musikcube.remote.ui.shared.util.NetworkUtil
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* When MP3 files are transcoded on-demand, the required metadata for gapless playback isn't
|
||||
* available yet. So, once we know the transocded files have been downloaded, we run this
|
||||
* service to fetch and replace the first 32000 bytes of the file, which should ensure the
|
||||
* required header is present, and subsequent plays are gapless.
|
||||
*/
|
||||
class GaplessHeaderService {
|
||||
@Inject lateinit var db: GaplessDb
|
||||
@Inject lateinit var streamProxy: StreamProxy
|
||||
|
||||
private val prefs = Application.instance!!.getSharedPreferences(Prefs.NAME, Context.MODE_PRIVATE)
|
||||
private val thread: HandlerThread
|
||||
private val handler: Handler
|
||||
private val httpClient: OkHttpClient
|
||||
|
||||
init {
|
||||
DaggerServiceComponent.builder()
|
||||
.appComponent(Application.appComponent)
|
||||
.build().inject(this)
|
||||
|
||||
val builder = OkHttpClient.Builder()
|
||||
.addInterceptor { chain ->
|
||||
var request = chain.request()
|
||||
val userPass = "default:" + prefs.getString(Prefs.Key.PASSWORD, Prefs.Default.PASSWORD)!!
|
||||
val encoded = Base64.encodeToString(userPass.toByteArray(), Base64.NO_WRAP)
|
||||
request = request.newBuilder().addHeader("Authorization", "Basic " + encoded).build()
|
||||
chain.proceed(request)
|
||||
}
|
||||
|
||||
if (prefs.getBoolean(Prefs.Key.CERT_VALIDATION_DISABLED, Prefs.Default.CERT_VALIDATION_DISABLED)) {
|
||||
NetworkUtil.disableCertificateValidation(builder)
|
||||
}
|
||||
|
||||
httpClient = builder.build()
|
||||
|
||||
thread = HandlerThread("GaplessHeaderService")
|
||||
thread.start()
|
||||
|
||||
handler = object : Handler(thread.looper) {
|
||||
override fun handleMessage(msg: Message?) {
|
||||
if (msg?.what == MESSAGE_PROCESS) {
|
||||
db.dao().queryByState(GaplessTrack.DOWNLOADED).forEach { process(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun schedule() {
|
||||
if (!handler.hasMessages(MESSAGE_PROCESS)) {
|
||||
handler.sendEmptyMessage(MESSAGE_PROCESS)
|
||||
}
|
||||
}
|
||||
|
||||
private fun process(gaplessTrack: GaplessTrack) {
|
||||
val url = gaplessTrack.url
|
||||
val fn = File(streamProxy.getProxyFilename(gaplessTrack.url))
|
||||
var newState = -1
|
||||
|
||||
if (fn.exists()) {
|
||||
/* the first 32000 bytes should be more than enough to snag the
|
||||
LAME header that contains gapless playback metadata. just rewrite
|
||||
those bytes in the already-downloaded file */
|
||||
val req = Request.Builder()
|
||||
.addHeader("Range", "bytes=0-32000")
|
||||
.url(url)
|
||||
.build()
|
||||
|
||||
var res: Response? = null
|
||||
|
||||
try {
|
||||
res = httpClient.newCall(req).execute()
|
||||
}
|
||||
catch (ex: Exception) {
|
||||
newState = GaplessTrack.DOWNLOADED
|
||||
}
|
||||
|
||||
if (res?.code() == 206) {
|
||||
val bytes = res.body()?.bytes()
|
||||
if (bytes?.isNotEmpty() == true) {
|
||||
RandomAccessFile(fn, "rw").use {
|
||||
it.seek(0)
|
||||
it.write(bytes)
|
||||
}
|
||||
newState = GaplessTrack.UPDATED
|
||||
}
|
||||
}
|
||||
else {
|
||||
newState = GaplessTrack.ERROR
|
||||
}
|
||||
}
|
||||
|
||||
if (newState == -1) {
|
||||
db.dao().deleteByUrl(gaplessTrack.url)
|
||||
}
|
||||
else {
|
||||
db.dao().update(newState, url)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val MESSAGE_PROCESS = 1
|
||||
}
|
||||
}
|
@ -1,7 +1,31 @@
|
||||
package io.casey.musikcube.remote.service.gapless.db;
|
||||
|
||||
import android.arch.persistence.room.Dao;
|
||||
import android.arch.persistence.room.Insert;
|
||||
import android.arch.persistence.room.OnConflictStrategy;
|
||||
import android.arch.persistence.room.Query;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Dao
|
||||
public class GaplessDao {
|
||||
public interface GaplessDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
void insert(GaplessTrack... track);
|
||||
|
||||
@Query("UPDATE GaplessTrack " +
|
||||
"SET state=:state " +
|
||||
"WHERE url=:url ")
|
||||
void update(int state, String url);
|
||||
|
||||
@Query("DELETE FROM GaplessTrack WHERE state=:state")
|
||||
void deleteByState(int state);
|
||||
|
||||
@Query("DELETE FROM GaplessTrack WHERE url=:url")
|
||||
void deleteByUrl(String url);
|
||||
|
||||
@Query("SELECT * FROM GaplessTrack WHERE state=:state")
|
||||
List<GaplessTrack> queryByState(int state);
|
||||
|
||||
@Query("SELECT * FROM GaplessTrack WHERE url=:url")
|
||||
List<GaplessTrack> queryByUrl(String url);
|
||||
}
|
||||
|
@ -2,8 +2,21 @@ package io.casey.musikcube.remote.service.gapless.db
|
||||
|
||||
import android.arch.persistence.room.Database
|
||||
import android.arch.persistence.room.RoomDatabase
|
||||
import io.reactivex.Single
|
||||
import io.reactivex.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.schedulers.Schedulers
|
||||
|
||||
@Database(entities = arrayOf(GaplessTrack::class), version = 1)
|
||||
abstract class GaplessDb: RoomDatabase() {
|
||||
abstract fun dao(): GaplessDao
|
||||
|
||||
fun prune() {
|
||||
Single.fromCallable {
|
||||
dao().deleteByState(GaplessTrack.DOWNLOADING)
|
||||
true
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe({ _ -> }, { })
|
||||
}
|
||||
}
|
@ -4,4 +4,11 @@ import android.arch.persistence.room.Entity
|
||||
import android.arch.persistence.room.PrimaryKey
|
||||
|
||||
@Entity
|
||||
data class GaplessTrack(@PrimaryKey val id: Long, val url: String, val state: Int)
|
||||
data class GaplessTrack(@PrimaryKey val id: Long?, val url: String, val state: Int) {
|
||||
companion object {
|
||||
val DOWNLOADING = 1
|
||||
val DOWNLOADED = 2
|
||||
val UPDATED = 3
|
||||
val ERROR = 4
|
||||
}
|
||||
}
|
@ -2,7 +2,10 @@ package io.casey.musikcube.remote.service.playback
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import io.casey.musikcube.remote.Application
|
||||
import io.casey.musikcube.remote.injection.*
|
||||
import io.casey.musikcube.remote.injection.DaggerPlaybackComponent
|
||||
import io.casey.musikcube.remote.service.gapless.GaplessHeaderService
|
||||
import io.casey.musikcube.remote.service.gapless.db.GaplessDb
|
||||
import io.casey.musikcube.remote.service.gapless.db.GaplessTrack
|
||||
import io.casey.musikcube.remote.service.playback.impl.player.ExoPlayerWrapper
|
||||
import io.casey.musikcube.remote.service.playback.impl.player.GaplessExoPlayerWrapper
|
||||
import io.casey.musikcube.remote.service.playback.impl.player.MediaPlayerWrapper
|
||||
@ -20,7 +23,9 @@ import javax.inject.Inject
|
||||
|
||||
abstract class PlayerWrapper {
|
||||
@Inject lateinit var offlineDb: OfflineDb
|
||||
@Inject lateinit var gaplessDb: GaplessDb
|
||||
@Inject lateinit var streamProxy: StreamProxy
|
||||
@Inject lateinit var gaplessService: GaplessHeaderService
|
||||
|
||||
init {
|
||||
DaggerPlaybackComponent.builder()
|
||||
@ -89,6 +94,11 @@ abstract class PlayerWrapper {
|
||||
offlineDb.trackDao().insertTrack(track)
|
||||
offlineDb.prune()
|
||||
}
|
||||
|
||||
gaplessDb.dao().queryByUrl(uri).forEach {
|
||||
gaplessDb.dao().update(GaplessTrack.DOWNLOADED, it.url)
|
||||
gaplessService.schedule()
|
||||
}
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
|
@ -7,17 +7,29 @@ import android.util.Base64
|
||||
import com.danikula.videocache.CacheListener
|
||||
import com.danikula.videocache.HttpProxyCacheServer
|
||||
import com.danikula.videocache.file.Md5FileNameGenerator
|
||||
import io.casey.musikcube.remote.Application
|
||||
import io.casey.musikcube.remote.injection.DaggerServiceComponent
|
||||
import io.casey.musikcube.remote.service.gapless.db.GaplessDb
|
||||
import io.casey.musikcube.remote.service.gapless.db.GaplessTrack
|
||||
import io.casey.musikcube.remote.ui.settings.constants.Prefs
|
||||
import io.casey.musikcube.remote.ui.shared.util.NetworkUtil
|
||||
import io.casey.musikcube.remote.util.Strings
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
import javax.inject.Inject
|
||||
|
||||
class StreamProxy(private val context: Context) {
|
||||
@Inject lateinit var gaplessDb: GaplessDb
|
||||
private lateinit var proxy: HttpProxyCacheServer
|
||||
private val prefs: SharedPreferences = context.getSharedPreferences(Prefs.NAME, Context.MODE_PRIVATE)
|
||||
|
||||
init {
|
||||
DaggerServiceComponent.builder()
|
||||
.appComponent(Application.appComponent)
|
||||
.build().inject(this)
|
||||
|
||||
gaplessDb.prune()
|
||||
|
||||
restart()
|
||||
}
|
||||
|
||||
@ -38,7 +50,7 @@ class StreamProxy(private val context: Context) {
|
||||
}
|
||||
|
||||
@Synchronized fun getProxyFilename(url: String): String {
|
||||
return FILENAME_GENERATOR(url)
|
||||
return proxy.cacheDirectory.canonicalPath + "/" + FILENAME_GENERATOR(url)
|
||||
}
|
||||
|
||||
@Synchronized fun reload() {
|
||||
@ -73,6 +85,21 @@ class StreamProxy(private val context: Context) {
|
||||
headers.put("Authorization", "Basic " + encoded)
|
||||
headers
|
||||
}
|
||||
.headerReceiver { url: String, headers: Map<String, List<String>> ->
|
||||
/* if we have a 'X-musikcube-Estimated-Content-Length' header in the response, that
|
||||
means the on-demand transcoder is running, therefore gapless information won't be
|
||||
available yet. track this download so we can patch up the header later, once the
|
||||
file finishes transcoding */
|
||||
val estimated = headers[ESTIMATED_LENGTH]
|
||||
if (estimated?.firstOrNull() == "true") {
|
||||
synchronized (proxy) {
|
||||
val dao = gaplessDb.dao()
|
||||
if (dao.queryByUrl(url).isEmpty()) {
|
||||
dao.insert(GaplessTrack(null, url, GaplessTrack.DOWNLOADING))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.fileNameGenerator(FILENAME_GENERATOR)
|
||||
.build()
|
||||
}
|
||||
@ -80,6 +107,7 @@ class StreamProxy(private val context: Context) {
|
||||
companion object {
|
||||
private val BYTES_PER_MEGABYTE = 1048576L
|
||||
private val BYTES_PER_GIGABYTE = 1073741824L
|
||||
private val ESTIMATED_LENGTH = "X-musikcube-Estimated-Content-Length"
|
||||
val MINIMUM_CACHE_SIZE_BYTES = BYTES_PER_MEGABYTE * 128
|
||||
|
||||
val CACHE_SETTING_TO_BYTES: MutableMap<Int, Long> = mutableMapOf(
|
||||
|
@ -259,7 +259,6 @@ class SystemService : Service() {
|
||||
this debouncer ensures we wait at least half a second between updates */
|
||||
private val mediaSessionDebouncer = object: Debouncer<Unit>(500) {
|
||||
override fun onDebounced(last: Unit?) {
|
||||
Log.e(TAG, String.format("updatePlaybackState: %s", sessionData.bitmap))
|
||||
val track = sessionData.track
|
||||
mediaSession?.setMetadata(MediaMetadataCompat.Builder()
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, track?.artist ?: "-")
|
||||
|
@ -6,57 +6,10 @@ import java.security.cert.CertificateException
|
||||
import javax.net.ssl.*
|
||||
|
||||
object NetworkUtil {
|
||||
private var sslContext: SSLContext? = null
|
||||
private var insecureSslSocketFactory: SSLSocketFactory? = null
|
||||
private var originalHttpsUrlConnectionSocketFactory: SSLSocketFactory? = null
|
||||
private var originalHttpsUrlConnectionHostnameVerifier: HostnameVerifier? = null
|
||||
|
||||
@Synchronized fun init() {
|
||||
if (originalHttpsUrlConnectionHostnameVerifier == null) {
|
||||
originalHttpsUrlConnectionSocketFactory = HttpsURLConnection.getDefaultSSLSocketFactory()
|
||||
originalHttpsUrlConnectionHostnameVerifier = HttpsURLConnection.getDefaultHostnameVerifier()
|
||||
}
|
||||
|
||||
if (sslContext == null) {
|
||||
try {
|
||||
sslContext = SSLContext.getInstance("TLS")
|
||||
sslContext?.init(null, trustAllCerts, java.security.SecureRandom())
|
||||
insecureSslSocketFactory = sslContext?.socketFactory
|
||||
}
|
||||
catch (ex: Exception) {
|
||||
throw RuntimeException(ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun disableCertificateValidation(okHttpClient: OkHttpClient.Builder) {
|
||||
okHttpClient.sslSocketFactory(insecureSslSocketFactory!!, trustAllCerts[0] as X509TrustManager)
|
||||
okHttpClient.hostnameVerifier { _, _ -> true }
|
||||
}
|
||||
|
||||
fun disableCertificateValidation(socketFactory: WebSocketFactory) {
|
||||
socketFactory.sslContext = sslContext
|
||||
}
|
||||
|
||||
fun disableCertificateValidation() {
|
||||
try {
|
||||
HttpsURLConnection.setDefaultSSLSocketFactory(insecureSslSocketFactory)
|
||||
HttpsURLConnection.setDefaultHostnameVerifier { _, _ -> true }
|
||||
}
|
||||
catch (e: Exception) {
|
||||
throw RuntimeException("should never happen")
|
||||
}
|
||||
}
|
||||
|
||||
fun enableCertificateValidation() {
|
||||
try {
|
||||
HttpsURLConnection.setDefaultSSLSocketFactory(originalHttpsUrlConnectionSocketFactory)
|
||||
HttpsURLConnection.setDefaultHostnameVerifier(originalHttpsUrlConnectionHostnameVerifier)
|
||||
}
|
||||
catch (e: Exception) {
|
||||
throw RuntimeException("should never happen")
|
||||
}
|
||||
}
|
||||
private var sslContext: SSLContext
|
||||
private var insecureSslSocketFactory: SSLSocketFactory
|
||||
private var originalHttpsUrlConnectionSocketFactory = HttpsURLConnection.getDefaultSSLSocketFactory()
|
||||
private var originalHttpsUrlConnectionHostnameVerifier = HttpsURLConnection.getDefaultHostnameVerifier()
|
||||
|
||||
private val trustAllCerts = arrayOf<TrustManager>(object : X509TrustManager {
|
||||
@Throws(CertificateException::class)
|
||||
@ -71,4 +24,31 @@ object NetworkUtil {
|
||||
return arrayOf()
|
||||
}
|
||||
})
|
||||
|
||||
init {
|
||||
originalHttpsUrlConnectionSocketFactory
|
||||
originalHttpsUrlConnectionHostnameVerifier
|
||||
sslContext = SSLContext.getInstance("TLS")
|
||||
sslContext.init(null, trustAllCerts, java.security.SecureRandom())
|
||||
insecureSslSocketFactory = sslContext.socketFactory
|
||||
}
|
||||
|
||||
fun disableCertificateValidation(okHttpClient: OkHttpClient.Builder) {
|
||||
okHttpClient.sslSocketFactory(insecureSslSocketFactory, trustAllCerts[0] as X509TrustManager)
|
||||
okHttpClient.hostnameVerifier { _, _ -> true }
|
||||
}
|
||||
|
||||
fun disableCertificateValidation(socketFactory: WebSocketFactory) {
|
||||
socketFactory.sslContext = sslContext
|
||||
}
|
||||
|
||||
fun disableCertificateValidation() {
|
||||
HttpsURLConnection.setDefaultSSLSocketFactory(insecureSslSocketFactory)
|
||||
HttpsURLConnection.setDefaultHostnameVerifier { _, _ -> true }
|
||||
}
|
||||
|
||||
fun enableCertificateValidation() {
|
||||
HttpsURLConnection.setDefaultSSLSocketFactory(originalHttpsUrlConnectionSocketFactory)
|
||||
HttpsURLConnection.setDefaultHostnameVerifier(originalHttpsUrlConnectionHostnameVerifier)
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user