diff --git a/android/build.gradle b/android/build.gradle index 2c57aa3..6958f59 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -5,7 +5,7 @@ buildscript { // version numbers ext.kotlin_version = '1.3.50' - ext.exo_player_version = '2.11.2' + ext.exo_player_version = '2.12.1' repositories { google() @@ -51,4 +51,6 @@ dependencies { implementation "com.google.android.exoplayer:exoplayer:$exo_player_version" implementation "com.google.android.exoplayer:extension-mediasession:$exo_player_version" + compileOnly files('tmplibs/flutter.jar') + } diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 01a286e..f3d0e8a 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ +#Thu Oct 15 12:19:13 EET 2020 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.2-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-6.1.1-all.zip diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index 53576c8..dc741f1 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -6,8 +6,9 @@ - - + + diff --git a/android/src/main/kotlin/me/sithiramunasinghe/flutter/flutter_radio_player/FlutterRadioPlayerPlugin.kt b/android/src/main/kotlin/me/sithiramunasinghe/flutter/flutter_radio_player/FlutterRadioPlayerPlugin.kt index bdd083e..2bb3bbb 100644 --- a/android/src/main/kotlin/me/sithiramunasinghe/flutter/flutter_radio_player/FlutterRadioPlayerPlugin.kt +++ b/android/src/main/kotlin/me/sithiramunasinghe/flutter/flutter_radio_player/FlutterRadioPlayerPlugin.kt @@ -1,10 +1,19 @@ package me.sithiramunasinghe.flutter.flutter_radio_player +import android.app.Activity +import android.app.PendingIntent +import android.app.PendingIntent.FLAG_UPDATE_CURRENT import android.content.* +import android.content.res.AssetFileDescriptor +import android.content.res.AssetManager +import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.os.IBinder import androidx.annotation.NonNull import androidx.localbroadcastmanager.content.LocalBroadcastManager import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.plugin.common.BinaryMessenger import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel.EventSink @@ -17,11 +26,15 @@ import io.flutter.plugin.common.PluginRegistry.Registrar import me.sithiramunasinghe.flutter.flutter_radio_player.core.PlayerItem import me.sithiramunasinghe.flutter.flutter_radio_player.core.StreamingCore import me.sithiramunasinghe.flutter.flutter_radio_player.core.enums.PlayerMethods +import java.util.* import java.util.logging.Logger +import kotlin.concurrent.schedule + /** FlutterRadioPlayerPlugin */ -public class FlutterRadioPlayerPlugin : FlutterPlugin, MethodCallHandler { +public class FlutterRadioPlayerPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { private var logger = Logger.getLogger(FlutterRadioPlayerPlugin::javaClass.name) + public var activity: Activity? = null private lateinit var methodChannel: MethodChannel @@ -29,6 +42,7 @@ public class FlutterRadioPlayerPlugin : FlutterPlugin, MethodCallHandler { private var mEventMetaDataSink: EventSink? = null companion object { + @JvmStatic fun registerWith(registrar: Registrar) { val instance = FlutterRadioPlayerPlugin() @@ -45,9 +59,11 @@ public class FlutterRadioPlayerPlugin : FlutterPlugin, MethodCallHandler { var isBound = false lateinit var applicationContext: Context lateinit var coreService: StreamingCore + var bitmap: Bitmap? = null lateinit var serviceIntent: Intent } + override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { buildEngine(flutterPluginBinding.applicationContext, flutterPluginBinding.binaryMessenger) } @@ -69,6 +85,11 @@ public class FlutterRadioPlayerPlugin : FlutterPlugin, MethodCallHandler { play() result.success(null) } + PlayerMethods.NEW_PLAY.value -> { + logger.info("newPlay service invoked") + newPlay() + result.success(null) + } PlayerMethods.PAUSE.value -> { logger.info("pause service invoked") pause() @@ -79,6 +100,13 @@ public class FlutterRadioPlayerPlugin : FlutterPlugin, MethodCallHandler { stop() result.success(null) } + PlayerMethods.SET_TITLE.value -> { + logger.info("setTitle service invoked") + val title = call.argument("title")!! + val subTitle = call.argument("subtitle")!! + coreService.setTitle(title,subTitle) + result.success(null) + } PlayerMethods.INIT.value -> { logger.info("start service invoked") init(call) @@ -93,6 +121,12 @@ public class FlutterRadioPlayerPlugin : FlutterPlugin, MethodCallHandler { PlayerMethods.SET_URL.value -> { logger.info("Set url invoked") val url = call.argument("streamUrl")!! + val coverByteArray = call.argument("coverImage") + if (coverByteArray != null){ + bitmap = BitmapFactory.decodeByteArray(coverByteArray,0 ,coverByteArray.size) + coreService.iconBitmap = bitmap + } + val playWhenReady = call.argument("playWhenReady")!! setUrl(url, playWhenReady) } @@ -118,7 +152,6 @@ public class FlutterRadioPlayerPlugin : FlutterPlugin, MethodCallHandler { applicationContext = context serviceIntent = Intent(applicationContext, StreamingCore::class.java) - initEventChannelStatus(messenger) initEventChannelMetaData(messenger) @@ -179,14 +212,25 @@ public class FlutterRadioPlayerPlugin : FlutterPlugin, MethodCallHandler { private fun init(methodCall: MethodCall) { logger.info("Attempting to initialize service...") + + val coverByteArray = methodCall.argument("coverImage") + if (coverByteArray != null){ + bitmap = BitmapFactory.decodeByteArray(coverByteArray,0 ,coverByteArray.size) + } + if (!isBound) { logger.info("Service not bound, binding now....") serviceIntent = setIntentData(serviceIntent, buildPlayerDetailsMeta(methodCall)) applicationContext.bindService(serviceIntent, serviceConnection, Context.BIND_IMPORTANT) applicationContext.startService(serviceIntent) + } else { + Timer("SettingUp", false).schedule(500) { + coreService.reEmmitSatus() + } } } + private fun isPlaying(): Boolean { logger.info("Attempting to get playing status....") val playingStatus = coreService.isPlaying() @@ -199,11 +243,17 @@ public class FlutterRadioPlayerPlugin : FlutterPlugin, MethodCallHandler { if (isPlaying()) pause() else play() } + private fun play() { - logger.info("Attempting to play music....") + logger.info("Attempting to Play music....") coreService.play() } + private fun newPlay() { + logger.info("Attempting to newPlay music....") + coreService.newPlay() + } + private fun pause() { logger.info("Attempting to pause music....") coreService.pause() @@ -244,14 +294,20 @@ public class FlutterRadioPlayerPlugin : FlutterPlugin, MethodCallHandler { override fun onServiceDisconnected(name: ComponentName?) { isBound = false // coreService = null + logger.info("Service Disconnected...") } override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { val localBinder = binder as StreamingCore.LocalBinder coreService = localBinder.service + coreService.activity = this@FlutterRadioPlayerPlugin.activity isBound = true +// coreService.reEmmitSatus() logger.info("Service Connection Established...") logger.info("Service bounded...") + + coreService.iconBitmap = bitmap + } } @@ -260,6 +316,7 @@ public class FlutterRadioPlayerPlugin : FlutterPlugin, MethodCallHandler { */ private var broadcastReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { + if (intent != null) { val returnStatus = intent.getStringExtra("status") logger.info("Received status: $returnStatus") @@ -281,4 +338,17 @@ public class FlutterRadioPlayerPlugin : FlutterPlugin, MethodCallHandler { } } } + + override fun onDetachedFromActivity() { + } + + override fun onReattachedToActivityForConfigChanges(p0: ActivityPluginBinding) { + } + + override fun onAttachedToActivity(p0: ActivityPluginBinding) { + this.activity = p0.activity + } + + override fun onDetachedFromActivityForConfigChanges() { + } } diff --git a/android/src/main/kotlin/me/sithiramunasinghe/flutter/flutter_radio_player/core/Constants.kt b/android/src/main/kotlin/me/sithiramunasinghe/flutter/flutter_radio_player/core/Constants.kt index a7eed65..72260df 100644 --- a/android/src/main/kotlin/me/sithiramunasinghe/flutter/flutter_radio_player/core/Constants.kt +++ b/android/src/main/kotlin/me/sithiramunasinghe/flutter/flutter_radio_player/core/Constants.kt @@ -4,5 +4,6 @@ const val FLUTTER_RADIO_PLAYER_STOPPED = "flutter_radio_stopped" const val FLUTTER_RADIO_PLAYER_PLAYING = "flutter_radio_playing" const val FLUTTER_RADIO_PLAYER_PAUSED = "flutter_radio_paused" const val FLUTTER_RADIO_PLAYER_LOADING = "flutter_radio_loading" +//const val FLUTTER_RADIO_PLAYER_READY = "flutter_radio_ready" const val FLUTTER_RADIO_PLAYER_ERROR = "flutter_radio_error" \ No newline at end of file diff --git a/android/src/main/kotlin/me/sithiramunasinghe/flutter/flutter_radio_player/core/CustomControlDispatcher.kt b/android/src/main/kotlin/me/sithiramunasinghe/flutter/flutter_radio_player/core/CustomControlDispatcher.kt new file mode 100644 index 0000000..77affee --- /dev/null +++ b/android/src/main/kotlin/me/sithiramunasinghe/flutter/flutter_radio_player/core/CustomControlDispatcher.kt @@ -0,0 +1,150 @@ +package me.sithiramunasinghe.flutter.flutter_radio_player.core + +import com.google.android.exoplayer2.C +import com.google.android.exoplayer2.ControlDispatcher +import com.google.android.exoplayer2.Player +import com.google.android.exoplayer2.Timeline + +/* +* Copyright (C) 2017 The Android Open Source Project +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ +/** Default [ControlDispatcher]. */ +class CustomControlDispatcher @JvmOverloads constructor( + /** Returns the fast forward increment in milliseconds. */ + @set:Deprecated("""Create a new instance instead and pass the new instance to the UI component. This + makes sure the UI gets updated and is in sync with the new values.""") var fastForwardIncrementMs: Long = CustomControlDispatcher.Companion.DEFAULT_FAST_FORWARD_MS.toLong(), + /** Returns the rewind increment in milliseconds. */ + @set:Deprecated("""Create a new instance instead and pass the new instance to the UI component. This + makes sure the UI gets updated and is in sync with the new values.""") var rewindIncrementMs: Long = CustomControlDispatcher.Companion.DEFAULT_REWIND_MS.toLong()) : ControlDispatcher { + private val window: Timeline.Window + + override fun dispatchSetPlayWhenReady(player: Player, playWhenReady: Boolean): Boolean { + if (!playWhenReady) + player.playWhenReady = playWhenReady + else{ + player.stop() + player.prepare() + player.playWhenReady = playWhenReady + } + return true + } + + override fun dispatchSeekTo(player: Player, windowIndex: Int, positionMs: Long): Boolean { + player.seekTo(windowIndex, positionMs) + return true + } + + override fun dispatchPrevious(player: Player): Boolean { + val timeline = player.currentTimeline + if (timeline.isEmpty || player.isPlayingAd) { + return true + } + val windowIndex = player.currentWindowIndex + timeline.getWindow(windowIndex, window) + val previousWindowIndex = player.previousWindowIndex + if (previousWindowIndex != C.INDEX_UNSET + && (player.currentPosition <= CustomControlDispatcher.Companion.MAX_POSITION_FOR_SEEK_TO_PREVIOUS + || window.isDynamic && !window.isSeekable)) { + player.seekTo(previousWindowIndex, C.TIME_UNSET) + } else { + player.seekTo(windowIndex, /* positionMs= */0) + } + return true + } + + override fun dispatchNext(player: Player): Boolean { + val timeline = player.currentTimeline + if (timeline.isEmpty || player.isPlayingAd) { + return true + } + val windowIndex = player.currentWindowIndex + val nextWindowIndex = player.nextWindowIndex + if (nextWindowIndex != C.INDEX_UNSET) { + player.seekTo(nextWindowIndex, C.TIME_UNSET) + } else if (timeline.getWindow(windowIndex, window).isLive) { + player.seekTo(windowIndex, C.TIME_UNSET) + } + return true + } + + override fun dispatchRewind(player: Player): Boolean { + if (isRewindEnabled && player.isCurrentWindowSeekable) { + CustomControlDispatcher.Companion.seekToOffset(player, -rewindIncrementMs) + } + return true + } + + override fun dispatchFastForward(player: Player): Boolean { + if (isFastForwardEnabled && player.isCurrentWindowSeekable) { + CustomControlDispatcher.Companion.seekToOffset(player, fastForwardIncrementMs) + } + return true + } + + override fun dispatchSetRepeatMode(player: Player, @Player.RepeatMode repeatMode: Int): Boolean { + player.repeatMode = repeatMode + return true + } + + override fun dispatchSetShuffleModeEnabled(player: Player, shuffleModeEnabled: Boolean): Boolean { + player.shuffleModeEnabled = shuffleModeEnabled + return true + } + + override fun dispatchStop(player: Player, reset: Boolean): Boolean { + player.stop(reset) + return true + } + + override fun isRewindEnabled(): Boolean { + return rewindIncrementMs > 0 + } + + override fun isFastForwardEnabled(): Boolean { + return fastForwardIncrementMs > 0 + } + + companion object { + /** The default fast forward increment, in milliseconds. */ + const val DEFAULT_FAST_FORWARD_MS = 15000 + + /** The default rewind increment, in milliseconds. */ + const val DEFAULT_REWIND_MS = 5000 + private const val MAX_POSITION_FOR_SEEK_TO_PREVIOUS = 3000 + + // Internal methods. + private fun seekToOffset(player: Player, offsetMs: Long) { + var positionMs = player.currentPosition + offsetMs + val durationMs = player.duration + if (durationMs != C.TIME_UNSET) { + positionMs = Math.min(positionMs, durationMs) + } + positionMs = Math.max(positionMs, 0) + player.seekTo(player.currentWindowIndex, positionMs) + } + } + /** + * Creates an instance with the given increments. + * + * @param fastForwardIncrementMs The fast forward increment in milliseconds. A non-positive value + * disables the fast forward operation. + * @param rewindIncrementMs The rewind increment in milliseconds. A non-positive value disables + * the rewind operation. + */ + /** Creates an instance. */ + init { + window = Timeline.Window() + } +} \ No newline at end of file diff --git a/android/src/main/kotlin/me/sithiramunasinghe/flutter/flutter_radio_player/core/CustomLoadControl.kt b/android/src/main/kotlin/me/sithiramunasinghe/flutter/flutter_radio_player/core/CustomLoadControl.kt new file mode 100644 index 0000000..346439a --- /dev/null +++ b/android/src/main/kotlin/me/sithiramunasinghe/flutter/flutter_radio_player/core/CustomLoadControl.kt @@ -0,0 +1,432 @@ +package me.sithiramunasinghe.flutter.flutter_radio_player.core + +import com.google.android.exoplayer2.C +import com.google.android.exoplayer2.LoadControl +import com.google.android.exoplayer2.Renderer +import com.google.android.exoplayer2.source.TrackGroupArray +import com.google.android.exoplayer2.trackselection.TrackSelectionArray +import com.google.android.exoplayer2.upstream.Allocator +import com.google.android.exoplayer2.upstream.DefaultAllocator +import com.google.android.exoplayer2.util.Assertions +import com.google.android.exoplayer2.util.Log +import com.google.android.exoplayer2.util.Util +// +//class CustomLoadControl { +//} + + +/** + * The default [LoadControl] implementation. + */ +class CustomLoadControl protected constructor( + allocator: DefaultAllocator, + minBufferMs: Int, + maxBufferMs: Int, + bufferForPlaybackMs: Int, + bufferForPlaybackAfterRebufferMs: Int, + targetBufferBytes: Int, + prioritizeTimeOverSizeThresholds: Boolean, + backBufferDurationMs: Int, + retainBackBufferFromKeyframe: Boolean) : LoadControl { + /** Builder for [CustomLoadControl]. */ + class Builder { + private var allocator: DefaultAllocator? = null + private var minBufferMs: Int + private var maxBufferMs: Int + private var bufferForPlaybackMs: Int + private var bufferForPlaybackAfterRebufferMs: Int + private var targetBufferBytes: Int + private var prioritizeTimeOverSizeThresholds: Boolean + private var backBufferDurationMs: Int + private var retainBackBufferFromKeyframe: Boolean + private var buildCalled = false + + /** + * Sets the [DefaultAllocator] used by the loader. + * + * @param allocator The [DefaultAllocator]. + * @return This builder, for convenience. + * @throws IllegalStateException If [.build] has already been called. + */ + fun setAllocator(allocator: DefaultAllocator?): Builder { + Assertions.checkState(!buildCalled) + this.allocator = allocator + return this + } + + /** + * Sets the buffer duration parameters. + * + * @param minBufferMs The minimum duration of media that the player will attempt to ensure is + * buffered at all times, in milliseconds. + * @param maxBufferMs The maximum duration of media that the player will attempt to buffer, in + * milliseconds. + * @param bufferForPlaybackMs The duration of media that must be buffered for playback to start + * or resume following a user action such as a seek, in milliseconds. + * @param bufferForPlaybackAfterRebufferMs The default duration of media that must be buffered + * for playback to resume after a rebuffer, in milliseconds. A rebuffer is defined to be + * caused by buffer depletion rather than a user action. + * @return This builder, for convenience. + * @throws IllegalStateException If [.build] has already been called. + */ + fun setBufferDurationsMs( + minBufferMs: Int, + maxBufferMs: Int, + bufferForPlaybackMs: Int, + bufferForPlaybackAfterRebufferMs: Int): Builder { + Assertions.checkState(!buildCalled) + assertGreaterOrEqual(bufferForPlaybackMs, 0, "bufferForPlaybackMs", "0") + assertGreaterOrEqual( + bufferForPlaybackAfterRebufferMs, 0, "bufferForPlaybackAfterRebufferMs", "0") + assertGreaterOrEqual(minBufferMs, bufferForPlaybackMs, "minBufferMs", "bufferForPlaybackMs") + assertGreaterOrEqual( + minBufferMs, + bufferForPlaybackAfterRebufferMs, + "minBufferMs", + "bufferForPlaybackAfterRebufferMs") + assertGreaterOrEqual(maxBufferMs, minBufferMs, "maxBufferMs", "minBufferMs") + this.minBufferMs = minBufferMs + this.maxBufferMs = maxBufferMs + this.bufferForPlaybackMs = bufferForPlaybackMs + this.bufferForPlaybackAfterRebufferMs = bufferForPlaybackAfterRebufferMs + return this + } + + /** + * Sets the target buffer size in bytes. If set to [C.LENGTH_UNSET], the target buffer + * size will be calculated based on the selected tracks. + * + * @param targetBufferBytes The target buffer size in bytes. + * @return This builder, for convenience. + * @throws IllegalStateException If [.build] has already been called. + */ + fun setTargetBufferBytes(targetBufferBytes: Int): Builder { + Assertions.checkState(!buildCalled) + this.targetBufferBytes = targetBufferBytes + return this + } + + /** + * Sets whether the load control prioritizes buffer time constraints over buffer size + * constraints. + * + * @param prioritizeTimeOverSizeThresholds Whether the load control prioritizes buffer time + * constraints over buffer size constraints. + * @return This builder, for convenience. + * @throws IllegalStateException If [.build] has already been called. + */ + fun setPrioritizeTimeOverSizeThresholds(prioritizeTimeOverSizeThresholds: Boolean): Builder { + Assertions.checkState(!buildCalled) + this.prioritizeTimeOverSizeThresholds = prioritizeTimeOverSizeThresholds + return this + } + + /** + * Sets the back buffer duration, and whether the back buffer is retained from the previous + * keyframe. + * + * @param backBufferDurationMs The back buffer duration in milliseconds. + * @param retainBackBufferFromKeyframe Whether the back buffer is retained from the previous + * keyframe. + * @return This builder, for convenience. + * @throws IllegalStateException If [.build] has already been called. + */ + fun setBackBuffer(backBufferDurationMs: Int, retainBackBufferFromKeyframe: Boolean): Builder { + Assertions.checkState(!buildCalled) + assertGreaterOrEqual(backBufferDurationMs, 0, "backBufferDurationMs", "0") + this.backBufferDurationMs = backBufferDurationMs + this.retainBackBufferFromKeyframe = retainBackBufferFromKeyframe + return this + } + + @Deprecated("use {@link #build} instead. ") + fun createCustomLoadControl(): CustomLoadControl { + return build() + } + + /** Creates a [CustomLoadControl]. */ + fun build(): CustomLoadControl { + Assertions.checkState(!buildCalled) + buildCalled = true + if (allocator == null) { + allocator = DefaultAllocator( /* trimOnReset= */true, C.DEFAULT_BUFFER_SEGMENT_SIZE) + } + return CustomLoadControl( + allocator!!, + minBufferMs, + maxBufferMs, + bufferForPlaybackMs, + bufferForPlaybackAfterRebufferMs, + targetBufferBytes, + prioritizeTimeOverSizeThresholds, + backBufferDurationMs, + retainBackBufferFromKeyframe) + } + + /** Constructs a new instance. */ + init { + minBufferMs = DEFAULT_MIN_BUFFER_MS + maxBufferMs = DEFAULT_MAX_BUFFER_MS + bufferForPlaybackMs = DEFAULT_BUFFER_FOR_PLAYBACK_MS + bufferForPlaybackAfterRebufferMs = DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS + targetBufferBytes = DEFAULT_TARGET_BUFFER_BYTES + prioritizeTimeOverSizeThresholds = DEFAULT_PRIORITIZE_TIME_OVER_SIZE_THRESHOLDS + backBufferDurationMs = DEFAULT_BACK_BUFFER_DURATION_MS + retainBackBufferFromKeyframe = DEFAULT_RETAIN_BACK_BUFFER_FROM_KEYFRAME + } + } + + private val allocator: DefaultAllocator + private val minBufferUs: Long + private val maxBufferUs: Long + private val bufferForPlaybackUs: Long + private val bufferForPlaybackAfterRebufferUs: Long + private val targetBufferBytesOverwrite: Int + private val prioritizeTimeOverSizeThresholds: Boolean + private val backBufferDurationUs: Long + private val retainBackBufferFromKeyframe: Boolean + private var targetBufferBytes: Int + private var isBuffering = false + + /** Constructs a new instance, using the `DEFAULT_*` constants defined in this class. */ + constructor() : this(DefaultAllocator(true, C.DEFAULT_BUFFER_SEGMENT_SIZE)) {} + + @Deprecated("Use {@link Builder} instead. ") + constructor(allocator: DefaultAllocator) : this( + allocator, + DEFAULT_MIN_BUFFER_MS, + DEFAULT_MAX_BUFFER_MS, + DEFAULT_BUFFER_FOR_PLAYBACK_MS, + DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS, + DEFAULT_TARGET_BUFFER_BYTES, + DEFAULT_PRIORITIZE_TIME_OVER_SIZE_THRESHOLDS, + DEFAULT_BACK_BUFFER_DURATION_MS, + DEFAULT_RETAIN_BACK_BUFFER_FROM_KEYFRAME) { + } + + @Deprecated("Use {@link Builder} instead. ") + constructor( + allocator: DefaultAllocator, + minBufferMs: Int, + maxBufferMs: Int, + bufferForPlaybackMs: Int, + bufferForPlaybackAfterRebufferMs: Int, + targetBufferBytes: Int, + prioritizeTimeOverSizeThresholds: Boolean) : this( + allocator, + minBufferMs, + maxBufferMs, + bufferForPlaybackMs, + bufferForPlaybackAfterRebufferMs, + targetBufferBytes, + prioritizeTimeOverSizeThresholds, + DEFAULT_BACK_BUFFER_DURATION_MS, + DEFAULT_RETAIN_BACK_BUFFER_FROM_KEYFRAME) { + } + + override fun onPrepared() { + reset(false) + } + + override fun onTracksSelected(renderers: Array, trackGroups: TrackGroupArray, + trackSelections: TrackSelectionArray) { + targetBufferBytes = if (targetBufferBytesOverwrite == C.LENGTH_UNSET) calculateTargetBufferBytes(renderers, trackSelections) else targetBufferBytesOverwrite + allocator.setTargetBufferSize(targetBufferBytes) + } + + override fun onStopped() { + reset(true) + } + + override fun onReleased() { + reset(true) + } + + override fun getAllocator(): Allocator { + return allocator + } + + override fun getBackBufferDurationUs(): Long { + return backBufferDurationUs + } + + override fun retainBackBufferFromKeyframe(): Boolean { + return retainBackBufferFromKeyframe + } + + override fun shouldContinueLoading( + playbackPositionUs: Long, bufferedDurationUs: Long, playbackSpeed: Float): Boolean { + + val targetBufferSizeReached = allocator.totalBytesAllocated >= targetBufferBytes + var minBufferUs = minBufferUs + if (playbackSpeed > 1) { + // The playback speed is faster than real time, so scale up the minimum required media + // duration to keep enough media buffered for a playout duration of minBufferUs. + val mediaDurationMinBufferUs = Util.getMediaDurationForPlayoutDuration(minBufferUs, playbackSpeed) + minBufferUs = Math.min(mediaDurationMinBufferUs, maxBufferUs) + } + + // Prevent playback from getting stuck if minBufferUs is too small. + minBufferUs = Math.max(minBufferUs, 500000) + if (bufferedDurationUs < minBufferUs) { + isBuffering = prioritizeTimeOverSizeThresholds || !targetBufferSizeReached + if (!isBuffering && bufferedDurationUs < 500000) { + Log.w( + "CustomLoadControl", + "Target buffer size reached with less than 500ms of buffered media data.") + } + } else if (bufferedDurationUs >= maxBufferUs || targetBufferSizeReached) { + isBuffering = false + } // Else don't change the buffering state + Log.d("CustomLoadControl", "bufferedDurationUs:$bufferedDurationUs," + + " playbackPositionUs:$playbackPositionUs," + + " maxBufferUs:$maxBufferUs," + + " isBuffering:$isBuffering," + + " targetBufferSizeReached:$targetBufferSizeReached") + return isBuffering + } + + override fun shouldStartPlayback( + bufferedDurationUs: Long, playbackSpeed: Float, rebuffering: Boolean): Boolean { + var bufferedDurationUs = bufferedDurationUs + bufferedDurationUs = Util.getPlayoutDurationForMediaDuration(bufferedDurationUs, playbackSpeed) + val minBufferDurationUs = if (rebuffering) bufferForPlaybackAfterRebufferUs else bufferForPlaybackUs + val shouldStart = minBufferDurationUs <= 0 || bufferedDurationUs >= minBufferDurationUs || (!prioritizeTimeOverSizeThresholds + && allocator.totalBytesAllocated >= targetBufferBytes) + Log.d("CustomLoadControl:shouldStartPlayback", "bufferedDurationUs:$bufferedDurationUs, shouldStart:$shouldStart") + + return shouldStart + } + + /** + * Calculate target buffer size in bytes based on the selected tracks. The player will try not to + * exceed this target buffer. Only used when `targetBufferBytes` is [C.LENGTH_UNSET]. + * + * @param renderers The renderers for which the track were selected. + * @param trackSelectionArray The selected tracks. + * @return The target buffer size in bytes. + */ + protected fun calculateTargetBufferBytes( + renderers: Array, trackSelectionArray: TrackSelectionArray): Int { + var targetBufferSize = 0 + for (i in renderers.indices) { + if (trackSelectionArray[i] != null) { + targetBufferSize += getDefaultBufferSize(renderers[i].trackType) + } + } + Log.d("calculateTargetBufferBytes","${Math.max(DEFAULT_MIN_BUFFER_SIZE, targetBufferSize)}") + return Math.max(DEFAULT_MIN_BUFFER_SIZE, targetBufferSize) + } + + private fun reset(resetAllocator: Boolean) { + targetBufferBytes = if (targetBufferBytesOverwrite == C.LENGTH_UNSET) DEFAULT_MIN_BUFFER_SIZE else targetBufferBytesOverwrite + isBuffering = false + if (resetAllocator) { + allocator.reset() + } + } + + companion object { + /** + * The default minimum duration of media that the player will attempt to ensure is buffered at all + * times, in milliseconds. + */ + const val DEFAULT_MIN_BUFFER_MS = 50000 + + /** + * The default maximum duration of media that the player will attempt to buffer, in milliseconds. + */ + const val DEFAULT_MAX_BUFFER_MS = 50000 + + /** + * The default duration of media that must be buffered for playback to start or resume following a + * user action such as a seek, in milliseconds. + */ + const val DEFAULT_BUFFER_FOR_PLAYBACK_MS = 2500 + + /** + * The default duration of media that must be buffered for playback to resume after a rebuffer, in + * milliseconds. A rebuffer is defined to be caused by buffer depletion rather than a user action. + */ + const val DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS = 5000 + + /** + * The default target buffer size in bytes. The value ([C.LENGTH_UNSET]) means that the load + * control will calculate the target buffer size based on the selected tracks. + */ + const val DEFAULT_TARGET_BUFFER_BYTES = C.LENGTH_UNSET + + /** The default prioritization of buffer time constraints over size constraints. */ + const val DEFAULT_PRIORITIZE_TIME_OVER_SIZE_THRESHOLDS = false + + /** The default back buffer duration in milliseconds. */ + const val DEFAULT_BACK_BUFFER_DURATION_MS = 0 + + /** The default for whether the back buffer is retained from the previous keyframe. */ + const val DEFAULT_RETAIN_BACK_BUFFER_FROM_KEYFRAME = false + + /** A default size in bytes for a video buffer. */ + const val DEFAULT_VIDEO_BUFFER_SIZE = 2000 * C.DEFAULT_BUFFER_SEGMENT_SIZE + + /** A default size in bytes for an audio buffer. */ + const val DEFAULT_AUDIO_BUFFER_SIZE = 200 * C.DEFAULT_BUFFER_SEGMENT_SIZE + + /** A default size in bytes for a text buffer. */ + const val DEFAULT_TEXT_BUFFER_SIZE = 2 * C.DEFAULT_BUFFER_SEGMENT_SIZE + + /** A default size in bytes for a metadata buffer. */ + const val DEFAULT_METADATA_BUFFER_SIZE = 2 * C.DEFAULT_BUFFER_SEGMENT_SIZE + + /** A default size in bytes for a camera motion buffer. */ + const val DEFAULT_CAMERA_MOTION_BUFFER_SIZE = 2 * C.DEFAULT_BUFFER_SEGMENT_SIZE + + /** A default size in bytes for a muxed buffer (e.g. containing video, audio and text). */ + const val DEFAULT_MUXED_BUFFER_SIZE = DEFAULT_VIDEO_BUFFER_SIZE + DEFAULT_AUDIO_BUFFER_SIZE + DEFAULT_TEXT_BUFFER_SIZE + + /** + * The buffer size in bytes that will be used as a minimum target buffer in all cases. This is + * also the default target buffer before tracks are selected. + */ + const val DEFAULT_MIN_BUFFER_SIZE = 200 * C.DEFAULT_BUFFER_SEGMENT_SIZE + private fun getDefaultBufferSize(trackType: Int): Int { + return when (trackType) { + C.TRACK_TYPE_DEFAULT -> DEFAULT_MUXED_BUFFER_SIZE + C.TRACK_TYPE_AUDIO -> DEFAULT_AUDIO_BUFFER_SIZE + C.TRACK_TYPE_VIDEO -> DEFAULT_VIDEO_BUFFER_SIZE + C.TRACK_TYPE_TEXT -> DEFAULT_TEXT_BUFFER_SIZE + C.TRACK_TYPE_METADATA -> DEFAULT_METADATA_BUFFER_SIZE + C.TRACK_TYPE_CAMERA_MOTION -> DEFAULT_CAMERA_MOTION_BUFFER_SIZE + C.TRACK_TYPE_NONE -> 0 + else -> throw IllegalArgumentException() + } + } + + private fun assertGreaterOrEqual(value1: Int, value2: Int, name1: String, name2: String) { + Assertions.checkArgument(value1 >= value2, "$name1 cannot be less than $name2") + } + } + + init { + assertGreaterOrEqual(bufferForPlaybackMs, 0, "bufferForPlaybackMs", "0") + assertGreaterOrEqual( + bufferForPlaybackAfterRebufferMs, 0, "bufferForPlaybackAfterRebufferMs", "0") + assertGreaterOrEqual(minBufferMs, bufferForPlaybackMs, "minBufferMs", "bufferForPlaybackMs") + assertGreaterOrEqual( + minBufferMs, + bufferForPlaybackAfterRebufferMs, + "minBufferMs", + "bufferForPlaybackAfterRebufferMs") + assertGreaterOrEqual(maxBufferMs, minBufferMs, "maxBufferMs", "minBufferMs") + assertGreaterOrEqual(backBufferDurationMs, 0, "backBufferDurationMs", "0") + this.allocator = allocator + minBufferUs = C.msToUs(minBufferMs.toLong()) + maxBufferUs = C.msToUs(maxBufferMs.toLong()) + bufferForPlaybackUs = C.msToUs(bufferForPlaybackMs.toLong()) + bufferForPlaybackAfterRebufferUs = C.msToUs(bufferForPlaybackAfterRebufferMs.toLong()) + targetBufferBytesOverwrite = targetBufferBytes + this.targetBufferBytes = if (targetBufferBytesOverwrite != C.LENGTH_UNSET) targetBufferBytesOverwrite else DEFAULT_MIN_BUFFER_SIZE + this.prioritizeTimeOverSizeThresholds = prioritizeTimeOverSizeThresholds + backBufferDurationUs = C.msToUs(backBufferDurationMs.toLong()) + this.retainBackBufferFromKeyframe = retainBackBufferFromKeyframe + } +} \ No newline at end of file diff --git a/android/src/main/kotlin/me/sithiramunasinghe/flutter/flutter_radio_player/core/StreamingCore.kt b/android/src/main/kotlin/me/sithiramunasinghe/flutter/flutter_radio_player/core/StreamingCore.kt index 3bb5809..32cb351 100644 --- a/android/src/main/kotlin/me/sithiramunasinghe/flutter/flutter_radio_player/core/StreamingCore.kt +++ b/android/src/main/kotlin/me/sithiramunasinghe/flutter/flutter_radio_player/core/StreamingCore.kt @@ -1,39 +1,43 @@ package me.sithiramunasinghe.flutter.flutter_radio_player.core -import android.app.Notification -import android.app.PendingIntent -import android.app.Service +import android.app.* +import android.content.Context import android.content.Intent import android.graphics.Bitmap +import android.media.AudioFocusRequest import android.media.AudioManager +import android.media.audiofx.AudioEffect import android.media.session.MediaSession import android.net.Uri import android.os.Binder import android.os.Build +import android.os.Handler import android.os.IBinder import android.support.v4.media.session.MediaSessionCompat import androidx.annotation.Nullable import androidx.localbroadcastmanager.content.LocalBroadcastManager -import com.google.android.exoplayer2.C -import com.google.android.exoplayer2.ExoPlaybackException -import com.google.android.exoplayer2.Player -import com.google.android.exoplayer2.SimpleExoPlayer +import com.google.android.exoplayer2.* import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector import com.google.android.exoplayer2.source.MediaSource import com.google.android.exoplayer2.source.ProgressiveMediaSource import com.google.android.exoplayer2.source.hls.HlsMediaSource import com.google.android.exoplayer2.ui.PlayerNotificationManager +import com.google.android.exoplayer2.upstream.DefaultAllocator import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory import com.google.android.exoplayer2.util.Util import me.sithiramunasinghe.flutter.flutter_radio_player.FlutterRadioPlayerPlugin.Companion.broadcastActionName import me.sithiramunasinghe.flutter.flutter_radio_player.FlutterRadioPlayerPlugin.Companion.broadcastChangedMetaDataName import me.sithiramunasinghe.flutter.flutter_radio_player.R import me.sithiramunasinghe.flutter.flutter_radio_player.core.enums.PlaybackStatus +import java.util.concurrent.TimeUnit import java.util.logging.Logger class StreamingCore : Service(), AudioManager.OnAudioFocusChangeListener { private var logger = Logger.getLogger(StreamingCore::javaClass.name) + var activity: Activity? = null + var iconBitmap: Bitmap? = null + private var isBound = false private val iBinder = LocalBinder() @@ -46,12 +50,42 @@ class StreamingCore : Service(), AudioManager.OnAudioFocusChangeListener { private val broadcastIntent = Intent(broadcastActionName) private val broadcastMetaDataIntent = Intent(broadcastChangedMetaDataName) + // class instances + private val handler = Handler(); + + private var audioManager: AudioManager? = null + private var focusRequest: AudioFocusRequest? = null private var player: SimpleExoPlayer? = null private var mediaSessionConnector: MediaSessionConnector? = null private var mediaSession: MediaSession? = null private var playerNotificationManager: PlayerNotificationManager? = null + var notificationTitle = "" + var notificationSubTitle = "" + + val afChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange -> + when (focusChange) { + AudioManager.AUDIOFOCUS_LOSS -> { + logger.info("AUDIOFOCUS_LOSS") + + pause() + handler.postDelayed(delayedStopRunnable, TimeUnit.SECONDS.toMillis(30)) + } + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { + logger.info("AUDIOFOCUS_LOSS_TRANSIENT") + pause() + } + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> { + setVolume(0.1) + } + AudioManager.AUDIOFOCUS_GAIN -> { + setVolume(1.0) + newPlay() + } + } + } + // session keys private val playbackNotificationId = 1025 private val mediaSessionId = "streaming_audio_player_media_session" @@ -69,7 +103,28 @@ class StreamingCore : Service(), AudioManager.OnAudioFocusChangeListener { fun play() { logger.info("playing audio $player ...") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + audioManager!!.requestAudioFocus(focusRequest!!) + } else { + audioManager!!.requestAudioFocus(afChangeListener, AudioEffect.CONTENT_TYPE_MUSIC, 0); + } + player?.playWhenReady = true + wasPlaying = false + + } + + fun newPlay() { + logger.info("new Play audio $player ...") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + audioManager!!.requestAudioFocus(focusRequest!!) + } else { + audioManager!!.requestAudioFocus(afChangeListener, AudioEffect.CONTENT_TYPE_MUSIC, 0); + } + player?.stop() + player?.prepare() player?.playWhenReady = true + wasPlaying = false + } fun pause() { @@ -77,12 +132,44 @@ class StreamingCore : Service(), AudioManager.OnAudioFocusChangeListener { player?.playWhenReady = false } + + fun reEmmitSatus() { + logger.info("reEmmtSatus ...") + if (this::playbackStatus.isInitialized) { + playbackStatus = when (playbackStatus) { + PlaybackStatus.PAUSED -> { + pushEvent(FLUTTER_RADIO_PLAYER_PAUSED) + PlaybackStatus.PAUSED + } + PlaybackStatus.PLAYING -> { + pushEvent(FLUTTER_RADIO_PLAYER_PLAYING) + PlaybackStatus.PLAYING + } + PlaybackStatus.LOADING -> { + pushEvent(FLUTTER_RADIO_PLAYER_LOADING) + PlaybackStatus.LOADING + + } + PlaybackStatus.STOPPED -> { + pushEvent(FLUTTER_RADIO_PLAYER_STOPPED) + PlaybackStatus.STOPPED + } + PlaybackStatus.ERROR -> { + pushEvent(FLUTTER_RADIO_PLAYER_ERROR) + PlaybackStatus.ERROR + } + } + } + } + fun isPlaying(): Boolean { val isPlaying = this.playbackStatus == PlaybackStatus.PLAYING logger?.info("is playing status: $isPlaying") return isPlaying } + var wasPlaying: Boolean = false + fun stop() { logger.info("stopping audio $player ...") player?.stop() @@ -90,6 +177,14 @@ class StreamingCore : Service(), AudioManager.OnAudioFocusChangeListener { isBound = false } + fun setTitle(title: String, subTitle: String) { + logger.info("settingTitle $title, $player ...") + this.notificationTitle = title + this.notificationSubTitle = subTitle + logger.info("calling playerNotificationManager.invalidate()...") + playerNotificationManager?.invalidate() + } + fun setVolume(volume: Double) { logger.info("Changing volume to : $volume") player?.volume = volume.toFloat() @@ -98,10 +193,16 @@ class StreamingCore : Service(), AudioManager.OnAudioFocusChangeListener { fun setUrl(streamUrl: String, playWhenReady: Boolean) { logger.info("ReadyPlay status: $playWhenReady") logger.info("Set stream URL: $streamUrl") - player?.prepare(buildMediaSource(dataSourceFactory, streamUrl)) + player?.setMediaSource(buildMediaSource(dataSourceFactory, streamUrl)) + player?.prepare() player?.playWhenReady = playWhenReady } + private var delayedStopRunnable = Runnable { +// stop() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { logger.info("Firing up service. (onStartCommand)...") @@ -111,21 +212,57 @@ class StreamingCore : Service(), AudioManager.OnAudioFocusChangeListener { logger.info("LocalBroadCastManager Received...") // get details - val appName = intent!!.getStringExtra("appName") - val subTitle = intent.getStringExtra("subTitle") + notificationTitle = intent!!.getStringExtra("appName") + notificationSubTitle = intent.getStringExtra("subTitle") val streamUrl = intent.getStringExtra("streamUrl") val playWhenReady = intent.getStringExtra("playWhenReady") == "true" + player = SimpleExoPlayer + .Builder(context) + .setLoadControl(CustomLoadControl + .Builder() + .setPrioritizeTimeOverSizeThresholds(true) + .setBufferDurationsMs(10000, + 10000, + 10000, + 10000) + .build() + ).build() + + + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN).run { + setAudioAttributes(android.media.AudioAttributes.Builder().run { + setUsage(android.media.AudioAttributes.USAGE_MEDIA) + setContentType(android.media.AudioAttributes.CONTENT_TYPE_MUSIC) + build() + }) + setAcceptsDelayedFocusGain(true) + setOnAudioFocusChangeListener(this@StreamingCore, handler) + build() + } + } + + audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager - player = SimpleExoPlayer.Builder(context).build() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + audioManager!!.requestAudioFocus(focusRequest) + } else { + audioManager!!.requestAudioFocus(afChangeListener, AudioEffect.CONTENT_TYPE_MUSIC, 0); + } - dataSourceFactory = DefaultDataSourceFactory(context, Util.getUserAgent(context, appName)) + dataSourceFactory = DefaultDataSourceFactory(context, Util.getUserAgent(context, notificationTitle)) val audioSource = buildMediaSource(dataSourceFactory, streamUrl) val playerEvents = object : Player.EventListener { - override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) { + override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) { playbackStatus = when (playbackState) { + Player.STATE_ENDED -> { + pushEvent(FLUTTER_RADIO_PLAYER_STOPPED) + PlaybackStatus.STOPPED + } Player.STATE_BUFFERING -> { pushEvent(FLUTTER_RADIO_PLAYER_LOADING) PlaybackStatus.LOADING @@ -137,10 +274,21 @@ class StreamingCore : Service(), AudioManager.OnAudioFocusChangeListener { Player.STATE_READY -> { setPlayWhenReady(playWhenReady) } - else -> setPlayWhenReady(playWhenReady) - } + else -> if (this@StreamingCore::playbackStatus.isInitialized) this@StreamingCore.playbackStatus else PlaybackStatus.STOPPED + } + if (playbackStatus == PlaybackStatus.PLAYING) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + this@StreamingCore.audioManager!!.requestAudioFocus(this@StreamingCore.focusRequest!!) + } else { + this@StreamingCore.audioManager!!.requestAudioFocus(this@StreamingCore.afChangeListener, AudioEffect.CONTENT_TYPE_MUSIC, 0); + } + } else { + logger.info("Remove player as a foreground notification...") + stopForeground(false) + } logger.info("onPlayerStateChanged: $playbackStatus") + } override fun onPlayerError(error: ExoPlaybackException) { @@ -154,7 +302,8 @@ class StreamingCore : Service(), AudioManager.OnAudioFocusChangeListener { player?.let { it.addListener(playerEvents) it.playWhenReady = playWhenReady - it.prepare(audioSource) + it.setMediaSource(audioSource) + it.prepare() } // register our meta data listener @@ -170,24 +319,29 @@ class StreamingCore : Service(), AudioManager.OnAudioFocusChangeListener { R.string.channel_description, playbackNotificationId, object : PlayerNotificationManager.MediaDescriptionAdapter { + override fun getCurrentContentTitle(player: Player): String { - return appName + logger.info("updating title = $notificationTitle") + return notificationTitle } @Nullable override fun createCurrentContentIntent(player: Player): PendingIntent { - return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + val intent = Intent(this@StreamingCore, activity!!.javaClass) + val contentPendingIntent = PendingIntent.getActivity(this@StreamingCore, 0, intent, 0); + return contentPendingIntent; } @Nullable override fun getCurrentContentText(player: Player): String? { - return subTitle + return null//notificationSubTitle } @Nullable override fun getCurrentLargeIcon(player: Player, callback: PlayerNotificationManager.BitmapCallback): Bitmap? { - return null // OS will use the application icon. + return this@StreamingCore.iconBitmap; // OS will use the application icon. } + }, object : PlayerNotificationManager.NotificationListener { override fun onNotificationCancelled(notificationId: Int, dismissedByUser: Boolean) { @@ -196,12 +350,15 @@ class StreamingCore : Service(), AudioManager.OnAudioFocusChangeListener { } override fun onNotificationPosted(notificationId: Int, notification: Notification, ongoing: Boolean) { - logger.info("Attaching player as a foreground notification...") + logger.info("Attaching player as a foreground notification...ongoing:$ongoing") startForeground(notificationId, notification) + if (!ongoing) { + stopForeground(false) + } } } ) - + this.playerNotificationManager = playerNotificationManager logger.info("Building Media Session and Player Notification.") val mediaSession = MediaSessionCompat(context, mediaSessionId) @@ -210,16 +367,22 @@ class StreamingCore : Service(), AudioManager.OnAudioFocusChangeListener { mediaSessionConnector = MediaSessionConnector(mediaSession) mediaSessionConnector?.setPlayer(player) + val dispatcher = CustomControlDispatcher() + dispatcher.fastForwardIncrementMs = 0 + dispatcher.rewindIncrementMs = 0 + + playerNotificationManager.setControlDispatcher(dispatcher) playerNotificationManager.setUseStopAction(true) playerNotificationManager.setFastForwardIncrementMs(0) playerNotificationManager.setRewindIncrementMs(0) playerNotificationManager.setUsePlayPauseActions(true) playerNotificationManager.setUseNavigationActions(false) + playerNotificationManager.setDefaults(Notification.DEFAULT_ALL) playerNotificationManager.setUseNavigationActionsInCompactView(false) - playerNotificationManager.setPlayer(player) playerNotificationManager.setMediaSessionToken(mediaSession.sessionToken) +// playerNotificationManager. playbackStatus = PlaybackStatus.PLAYING return START_STICKY @@ -247,16 +410,23 @@ class StreamingCore : Service(), AudioManager.OnAudioFocusChangeListener { AudioManager.AUDIOFOCUS_GAIN -> { player?.volume = 0.8f - play() + if (wasPlaying) { + newPlay() + } } AudioManager.AUDIOFOCUS_LOSS -> { - stop() + logger.info("AudioManager.AUDIOFOCUS_LOSS") + if (isPlaying()) { + pause() + } } AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { if (isPlaying()) { - stop() + logger.info("AudioManager.AUDIOFOCUS_LOSS_TRANSIENT") + pause() + wasPlaying = true } } @@ -273,7 +443,7 @@ class StreamingCore : Service(), AudioManager.OnAudioFocusChangeListener { */ private fun pushEvent(eventName: String) { logger.info("Pushing Event: $eventName") - localBroadcastManager.sendBroadcast(broadcastIntent.putExtra("status", eventName)) + localBroadcastManager.sendBroadcast(Intent(broadcastActionName).putExtra("status", eventName)) } /** @@ -284,8 +454,11 @@ class StreamingCore : Service(), AudioManager.OnAudioFocusChangeListener { val uri = Uri.parse(streamUrl) return when (val type = Util.inferContentType(uri)) { - C.TYPE_HLS -> HlsMediaSource.Factory(dataSourceFactory).createMediaSource(uri) - C.TYPE_OTHER -> ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(uri) +// C.TYPE_DASH -> DashMediaSource.Factory(dataSourceFactory).createMediaSource(MediaItem.fromUri(uri)) + C.TYPE_HLS -> HlsMediaSource.Factory(dataSourceFactory).createMediaSource(MediaItem.fromUri(uri)) + C.TYPE_OTHER -> ProgressiveMediaSource.Factory(dataSourceFactory) +// .setContinueLoadingCheckIntervalBytes(1024*32) + .createMediaSource(MediaItem.fromUri(uri)) else -> { throw IllegalStateException("Unsupported type: $type") } diff --git a/android/src/main/kotlin/me/sithiramunasinghe/flutter/flutter_radio_player/core/enums/PlayerMethods.kt b/android/src/main/kotlin/me/sithiramunasinghe/flutter/flutter_radio_player/core/enums/PlayerMethods.kt index 3974767..e0ef509 100644 --- a/android/src/main/kotlin/me/sithiramunasinghe/flutter/flutter_radio_player/core/enums/PlayerMethods.kt +++ b/android/src/main/kotlin/me/sithiramunasinghe/flutter/flutter_radio_player/core/enums/PlayerMethods.kt @@ -2,10 +2,15 @@ package me.sithiramunasinghe.flutter.flutter_radio_player.core.enums enum class PlayerMethods(val value: String) { INIT("initService"), +// INIT("setC"), + PLAY_PAUSE("playOrPause"), PLAY("play"), + NEW_PLAY("newPlay"), + PAUSE("pause"), STOP("stop"), + SET_TITLE("setTitle"), SET_URL("setUrl"), IS_PLAYING("isPlaying"), SET_VOLUME("setVolume") diff --git a/android/tmplibs/flutter.jar b/android/tmplibs/flutter.jar new file mode 100644 index 0000000..21a1d8d Binary files /dev/null and b/android/tmplibs/flutter.jar differ diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 29b044d..8f83641 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -19,4 +19,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 1b66dae606f75376c5f2135a8290850eeb09ae83 -COCOAPODS: 1.8.4 +COCOAPODS: 1.9.3 diff --git a/example/lib/main.dart b/example/lib/main.dart index d507ac7..9948f3d 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -118,7 +118,7 @@ class _MyAppState extends State { return Text(snapshot.data); }), RaisedButton(child: Text("Change URL"), onPressed: () async { - _flutterRadioPlayer.setUrl("http://209.133.216.3:7018/;stream.mp3", "false"); + _flutterRadioPlayer.setUrl("http://209.133.216.3:7018/;stream.mp3", "false",); }) ], ), diff --git a/example/pubspec.lock b/example/pubspec.lock index 2937257..2ef5255 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -68,7 +68,7 @@ packages: path: ".." relative: true source: path - version: "1.0.5" + version: "1.0.7" flutter_test: dependency: "direct dev" description: flutter @@ -158,3 +158,4 @@ packages: version: "2.0.8" sdks: dart: ">=2.9.0-14.0.dev <3.0.0" + flutter: ">=1.12.0 <2.0.0" diff --git a/ios/Classes/SwiftFlutterRadioPlayerPlugin.swift b/ios/Classes/SwiftFlutterRadioPlayerPlugin.swift index 00caf71..1606e0f 100644 --- a/ios/Classes/SwiftFlutterRadioPlayerPlugin.swift +++ b/ios/Classes/SwiftFlutterRadioPlayerPlugin.swift @@ -31,10 +31,14 @@ public class SwiftFlutterRadioPlayerPlugin: NSObject, FlutterPlugin { let subTitle = args["subTitle"] as? String, let playWhenReady = args["playWhenReady"] as? String { + if !streamingCore.isFirstTime{ + NotificationCenter.default.removeObserver(self, name: Notifications.playbackNotification, object: nil) + } + NotificationCenter.default.addObserver(self, selector: #selector(onRecieve(_:)), name: Notifications.playbackNotification, object: nil) + streamingCore.initService(streamURL: streamURL, serviceName: appName, secondTitle: subTitle, playWhenReady: playWhenReady) - NotificationCenter.default.addObserver(self, selector: #selector(onRecieve(_:)), name: Notifications.playbackNotification, object: nil) - result(nil) + result(false) } break case "playOrPause": @@ -52,6 +56,25 @@ public class SwiftFlutterRadioPlayerPlugin: NSObject, FlutterPlugin { } result(false) break + + case "newPlay": + print("method called to newPlay from service") + let status = streamingCore.newPlay() + if (status == PlayerStatus.PLAYING) { + result(true) + } + result(false) + break + case "setTitle": + print("method called to set title from service") + if let args = call.arguments as? Dictionary, + let title = args["title"] as? String, + let subTitle = args["subtitle"] as? String + { + streamingCore.setTitle(title:title,subTitle:subTitle) + } + result(false) + break case "pause": print("method called to play from service") let status = streamingCore.pause() @@ -93,6 +116,7 @@ public class SwiftFlutterRadioPlayerPlugin: NSObject, FlutterPlugin { result(nil) } } + @objc private func onRecieve(_ notification: Notification) { // unwrapping optional @@ -105,7 +129,10 @@ public class SwiftFlutterRadioPlayerPlugin: NSObject, FlutterPlugin { print("Notification received with metada: \(metaDataEvent)") SwiftFlutterRadioPlayerPlugin.eventSinkMetadata?(metaDataEvent as! String) } - + } + + deinit { + print("DeinitPlugin") } } diff --git a/ios/Classes/core/StreamingCore.swift b/ios/Classes/core/StreamingCore.swift index a173ec3..84932da 100644 --- a/ios/Classes/core/StreamingCore.swift +++ b/ios/Classes/core/StreamingCore.swift @@ -16,28 +16,56 @@ class StreamingCore : NSObject, AVPlayerItemMetadataOutputPushDelegate { private var playerItemContext = 0 private var commandCenter: MPRemoteCommandCenter? private var playWhenReady: Bool = false + private var wasPlaying: Bool = false + + private var streamUrl:String = "" + + var playerStatus: String = Constants.FLUTTER_RADIO_STOPPED + override init() { print("StreamingCore Initializing...") } - func initService(streamURL: String, serviceName: String, secondTitle: String, playWhenReady: String) -> Void { + deinit { + print("StreamingCore Deinitializing...") + self.avPlayer?.removeObserver(self, forKeyPath: #keyPath(AVPlayer.status)) + self.avPlayer?.removeObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem.status)) + self.avPlayer?.removeObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem.isPlaybackBufferEmpty)) + } + + fileprivate func removeAvPlayerObserversIfSubscribed() { + if !isFirstTime { + + NotificationCenter.default.removeObserver(self, name: .AVPlayerItemNewErrorLogEntry, object: nil) + NotificationCenter.default.removeObserver(self, name: .AVPlayerItemFailedToPlayToEndTime, object: nil) + NotificationCenter.default.removeObserver(self, name: .AVPlayerItemPlaybackStalled, object: nil) + + self.avPlayer?.removeObserver(self, forKeyPath: #keyPath(AVPlayer.status)) + self.avPlayer?.removeObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem.status)) + self.avPlayer?.removeObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem.isPlaybackBufferEmpty)) + + } + } + + func initService(streamURL: String, serviceName: String, secondTitle: String, playWhenReady: String) -> Void { + self.streamUrl = streamURL print("Initialing Service...") - + print("Stream url: " + streamURL) - + let streamURLInstance = URL(string: streamURL) - + removeAvPlayerObserversIfSubscribed() // Setting up AVPlayer avPlayerItem = AVPlayerItem(url: streamURLInstance!) avPlayer = AVPlayer(playerItem: avPlayerItem!) - + //Listener for metadata from streaming let metadataOutput = AVPlayerItemMetadataOutput(identifiers: nil) metadataOutput.setDelegate(self, queue: DispatchQueue.main) avPlayerItem?.add(metadataOutput) - + if playWhenReady == "true" { print("PlayWhenReady: true") self.playWhenReady = true @@ -45,9 +73,85 @@ class StreamingCore : NSObject, AVPlayerItemMetadataOutputPushDelegate { // initialize player observers initPlayerObservers() - + // init Remote protocols. initRemoteTransportControl(appName: serviceName, subTitle: secondTitle); + setupNotifications() + + if #available(iOS 10.0, *) { + avPlayerItem?.preferredForwardBufferDuration = 10 + } + + let notificationCenter = NotificationCenter.default + if !isFirstTime{ + notificationCenter.removeObserver(self, name: UIApplication.didBecomeActiveNotification, object: nil) + } + notificationCenter.addObserver(self, selector: #selector(appMovedToForeground), name: UIApplication.didBecomeActiveNotification, object: nil) + + isFirstTime = false + } + + @objc + func appMovedToForeground() { + print("Reemmiting the current state!") + pushEvent(eventName: playerStatus) + } + + var isFirstTime = true + + func setupNotifications() { + // Get the default notification center instance. + let nc = NotificationCenter.default + if !isFirstTime{ + nc.removeObserver(self, name: AVAudioSession.interruptionNotification, object: nil) + } + nc.addObserver(self, + selector: #selector(handleInterruption), + name: AVAudioSession.interruptionNotification, + object: nil) + + } + + @objc func handleInterruption(notification: Notification) { + // To be implemented. + guard let userInfo = notification.userInfo, + let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt, + let type = AVAudioSession.InterruptionType(rawValue: typeValue) + else { + return + } + + switch type { + case .began: + if #available(iOS 10.3, *) { +// let suspendedKey = userInfo[AVAudioSessionInterruptionWasSuspendedKey] as? NSNumber ?? 0 +// if suspendedKey == 0 { +// wasPlaying = false +// _ = pause() +// } else { + if playerStatus == Constants.FLUTTER_RADIO_PLAYING{ + wasPlaying = true + _ = pause() + } +// } + } else { + wasPlaying = false + _ = pause() + // Fallback on earlier versions + } + + + print("an intrruption has begun") + break + case .ended: + if let optionValue = (notification.userInfo?[AVAudioSessionInterruptionOptionKey] as? NSNumber)?.uintValue, AVAudioSession.InterruptionOptions(rawValue: optionValue) == .shouldResume { + _ = play() + wasPlaying = false + } + + break + default: () + } } func metadataOutput(_ output: AVPlayerItemMetadataOutput, didOutputTimedMetadataGroups groups: [AVTimedMetadataGroup], from track: AVPlayerItemTrack?) { @@ -55,23 +159,33 @@ class StreamingCore : NSObject, AVPlayerItemMetadataOutputPushDelegate { { item.value(forKeyPath: "value") let song = (item.value(forKeyPath: "value")!) - pushEvent(typeEvent: "meta_data",eventName: song as! String) + pushEvent(typeEvent: "meta_data",eventName: song as! String) }} func play() -> PlayerStatus { print("invoking play method on service") + playerStatus = Constants.FLUTTER_RADIO_PLAYING if(!isPlaying()) { avPlayer?.play() pushEvent(eventName: Constants.FLUTTER_RADIO_PLAYING) } - + return PlayerStatus.PLAYING + } + + func newPlay() -> PlayerStatus { + print("invoking play method on service") + playerStatus = Constants.FLUTTER_RADIO_PLAYING + let streamURLInstance = URL(string: streamUrl) + playWhenReady = true + avPlayer?.replaceCurrentItem(with: AVPlayerItem(url: streamURLInstance!)) return PlayerStatus.PLAYING } func pause() -> PlayerStatus { print("invoking pause method on service") + playerStatus = Constants.FLUTTER_RADIO_PAUSED if (isPlaying()) { avPlayer?.pause() pushEvent(eventName: Constants.FLUTTER_RADIO_PAUSED) @@ -82,11 +196,14 @@ class StreamingCore : NSObject, AVPlayerItemMetadataOutputPushDelegate { func stop() -> PlayerStatus { print("invoking stop method on service") + playerStatus = Constants.FLUTTER_RADIO_STOPPED if (isPlaying()) { pushEvent(eventName: Constants.FLUTTER_RADIO_STOPPED) + avPlayer = nil avPlayerItem = nil commandCenter = nil + } return PlayerStatus.STOPPED @@ -103,17 +220,24 @@ class StreamingCore : NSObject, AVPlayerItemMetadataOutputPushDelegate { print("Setting volume to: \(formattedVolume)") avPlayer?.volume = formattedVolume } + + func setTitle(title: String, subTitle:String) -> Void { + print("Setting title to: \(title)") + let nowPlayingInfo = [MPMediaItemPropertyTitle : title, MPMediaItemPropertyArtist: subTitle] + MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo + } func setUrl(streamURL: String, playWhenReady: String) -> Void { + self.streamUrl = streamURL let streamURLInstance = URL(string: streamURL) avPlayer?.replaceCurrentItem(with: AVPlayerItem(url: streamURLInstance!)) if playWhenReady == "true" { self.playWhenReady = true - play() + _ = play() } else { self.playWhenReady = false - pause() + _ = pause() } } @@ -155,9 +279,10 @@ class StreamingCore : NSObject, AVPlayerItemMetadataOutputPushDelegate { } // control center play button callback +// commandCenter?.playCommand.remo commandCenter?.playCommand.addTarget { (MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus in print("command center play command...") - _ = self.play() + _ = self.newPlay() return .success } @@ -179,9 +304,10 @@ class StreamingCore : NSObject, AVPlayerItemMetadataOutputPushDelegate { let audioSession = AVAudioSession.sharedInstance() if #available(iOS 10.0, *) { - try audioSession.setCategory(.playback, mode: .default, options: .defaultToSpeaker) - try audioSession.overrideOutputAudioPort(.speaker) - try audioSession.setActive(true) + + try? audioSession.setCategory(.playback, mode: .default) + try? audioSession.overrideOutputAudioPort(.speaker) + try? audioSession.setActive(true) } UIApplication.shared.beginReceivingRemoteControlEvents() @@ -189,29 +315,58 @@ class StreamingCore : NSObject, AVPlayerItemMetadataOutputPushDelegate { print("Something went wrong ! \(error)") } } - + private func initPlayerObservers() { print("Initializing player observers...") - // Add observer for AVPlayer.Status and AVPlayerItem.currentItem - self.avPlayer?.addObserver(self, forKeyPath: #keyPath(AVPlayer.status), options: [.new, .initial], context: nil) - self.avPlayer?.addObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem.status), options:[.new, .initial], context: nil) - self.avPlayer?.addObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem.isPlaybackBufferEmpty), options:[.new, .initial], context: nil) + + + NotificationCenter.default.addObserver(self, selector: #selector(itemNewErrorLogEntry(_:)), name: .AVPlayerItemNewErrorLogEntry, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(itemFailedToPlayToEndTime(_:)), name: .AVPlayerItemFailedToPlayToEndTime, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(itemPlaybackStalled(_:)), name: .AVPlayerItemPlaybackStalled, object: nil) + + self.avPlayer?.addObserver(self, forKeyPath: #keyPath(AVPlayer.status), options: [.new,.initial], context: nil) + self.avPlayer?.addObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem.status), options:[.new,.initial], context: nil) + self.avPlayer?.addObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem.isPlaybackBufferEmpty), options:[.new,.initial], context: nil) + } + + + + @objc func itemNewErrorLogEntry(_ notification:Notification){ + print(notification) + } + + @objc func itemFailedToPlayToEndTime(_ notification:Notification){ + if let _ = notification.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey]{ + _ = stop() + print("Observer: Failed...\(notification.userInfo)") + playerStatus = Constants.FLUTTER_RADIO_ERROR + + pushEvent(eventName: Constants.FLUTTER_RADIO_ERROR) + } + } + @objc func itemPlaybackStalled(_ notification:Notification){ + _ = stop() + print("Observer: Stalled...") + playerStatus = Constants.FLUTTER_RADIO_ERROR + + pushEvent(eventName: Constants.FLUTTER_RADIO_ERROR) } override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) { if object is AVPlayer { + switch keyPath { - case #keyPath(AVPlayer.currentItem.isPlaybackBufferEmpty): let _: Bool if let newStatusNumber = change?[NSKeyValueChangeKey.newKey] as? Bool { if newStatusNumber { print("Observer: Stalling...") + playerStatus = Constants.FLUTTER_RADIO_LOADING + pushEvent(eventName: Constants.FLUTTER_RADIO_LOADING) } } - case #keyPath(AVPlayer.currentItem.status): let newStatus: AVPlayerItem.Status if let newStatusAsNumber = change?[NSKeyValueChangeKey.newKey] as? NSNumber { @@ -219,30 +374,48 @@ class StreamingCore : NSObject, AVPlayerItemMetadataOutputPushDelegate { } else { newStatus = .unknown } - if newStatus == .readyToPlay { print("Observer: Ready to play...") if (!isPlaying()) { if (self.playWhenReady) { - play() - } - pushEvent(eventName: Constants.FLUTTER_RADIO_PAUSED) + _ = play() + }else{ + playerStatus = Constants.FLUTTER_RADIO_PAUSED + pushEvent(eventName: Constants.FLUTTER_RADIO_PAUSED)} } else { + playerStatus = Constants.FLUTTER_RADIO_PLAYING + pushEvent(eventName: Constants.FLUTTER_RADIO_PLAYING) } } if newStatus == .failed { print("Observer: Failed...") + playerStatus = Constants.FLUTTER_RADIO_ERROR + pushEvent(eventName: Constants.FLUTTER_RADIO_ERROR) } case #keyPath(AVPlayer.status): - print() + var newStatus: AVPlayerItem.Status + if let newStatusAsNumber = change?[NSKeyValueChangeKey.newKey] as? NSNumber { + newStatus = AVPlayerItem.Status(rawValue: newStatusAsNumber.intValue)! + } else { + newStatus = .unknown + } + + if newStatus == .failed { + print("Observer: Failed...") + + playerStatus = Constants.FLUTTER_RADIO_ERROR + + pushEvent(eventName: Constants.FLUTTER_RADIO_ERROR) + } case .none: print("none...") case .some(_): print("some...") } +// print(keyPath) } } diff --git a/lib/flutter_radio_player.dart b/lib/flutter_radio_player.dart index 579f9af..a3fc1fa 100644 --- a/lib/flutter_radio_player.dart +++ b/lib/flutter_radio_player.dart @@ -23,12 +23,13 @@ class FlutterRadioPlayer { static Stream _metaDataStream; Future init(String appName, String subTitle, String streamURL, - String playWhenReady) async { + String playWhenReady,{ByteData coverImage}) async { return await _channel.invokeMethod("initService", { "appName": appName, "subTitle": subTitle, "streamURL": streamURL, - "playWhenReady": playWhenReady + "playWhenReady": playWhenReady, + "coverImage": coverImage.buffer.asUint8List() }); } @@ -36,6 +37,10 @@ class FlutterRadioPlayer { return await _channel.invokeMethod("play"); } + Future newPlay() async { + return await _channel.invokeMethod("newPlay"); + } + Future pause() async { return await _channel.invokeMethod("pause"); } @@ -58,10 +63,18 @@ class FlutterRadioPlayer { await _channel.invokeMethod("setVolume", {"volume": volume}); } - Future setUrl(String streamUrl, String playWhenReady) async { + Future setTitle(String title, String subtitle) async { + await _channel.invokeMethod("setTitle", { + "title": title, + "subtitle": subtitle + }); + } + + Future setUrl(String streamUrl, String playWhenReady, ByteData coverImage) async { await _channel.invokeMethod("setUrl", { "playWhenReady": playWhenReady, - "streamUrl": streamUrl + "streamUrl": streamUrl, + "coverImage": coverImage.buffer.asUint8List() }); } diff --git a/pubspec.lock b/pubspec.lock index 9477cdd..fcfae0a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -144,3 +144,4 @@ packages: version: "2.0.8" sdks: dart: ">=2.9.0-14.0.dev <3.0.0" + flutter: ">=1.12.0 <2.0.0"