diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d4bd8ea0..cad31abad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +## 0.0.78 +* [BREAKING_CHANGE] Split controlsHidden into controlsHiddenStart and controlsHiddenEnd. +* [BREAKING_CHANGE] Added to Function(bool) onPlayerVisibilityChanged to customControlsBuilder in [BetterPlayerConfiguration]. +* Migrated android native code to Kotlin. +* Updated ExoPlayer version to 2.15.1. +* Updated screenshots. +* Added onTapDown handle for material and cupertino progress bar to handle show and hide of controls. +* Fixed crash related to Android 12. +* Fixed issue with full url of subtitle for HLS data source. +* Fixed install page from docs. +* Fixed one of the showcase images. +* Fixed video in list example. + ## 0.0.77 * Fixed full screen safe area issue in cupertino controls. * Fixed subtitles duplication after changing data source. diff --git a/android/build.gradle b/android/build.gradle index 208c538da..cb0fde023 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,14 +1,22 @@ group 'com.jhomlala.better_player' -version '1.0-SNAPSHOT' +version '0.0.77' buildscript { + + ext.exoPlayerVersion = "2.15.1" + ext.lifecycleVersion = "2.4.0-beta01" + ext.annotationVersion = "1.2.0" + ext.workVersion = "2.7.0" + ext.coreVersion = "1.6.0" + ext.gradleVersion = "4.1.0" + repositories { google() jcenter() } dependencies { - classpath 'com.android.tools.build:gradle:3.3.0' + classpath "com.android.tools.build:gradle:$gradleVersion" } } @@ -20,9 +28,10 @@ rootProject.allprojects { } apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' android { - compileSdkVersion 28 + compileSdkVersion 31 defaultConfig { minSdkVersion 16 @@ -39,19 +48,25 @@ android { } dependencies { - implementation 'com.google.android.exoplayer:exoplayer-core:2.14.2' - implementation 'com.google.android.exoplayer:exoplayer-hls:2.14.2' - implementation 'com.google.android.exoplayer:exoplayer-dash:2.14.2' - implementation 'com.google.android.exoplayer:exoplayer-smoothstreaming:2.14.2' - implementation 'com.google.android.exoplayer:exoplayer-ui:2.14.2' - implementation 'com.google.android.exoplayer:extension-mediasession:2.14.2' - implementation "android.arch.lifecycle:runtime:1.1.1" - implementation "android.arch.lifecycle:common:1.1.1" - implementation "android.arch.lifecycle:common-java8:1.1.1" - implementation 'androidx.annotation:annotation:1.1.0' - implementation 'androidx.legacy:legacy-support-v4:1.0.0' - implementation "androidx.work:work-runtime:2.5.0" + implementation "com.google.android.exoplayer:exoplayer-core:$exoPlayerVersion" + implementation "com.google.android.exoplayer:exoplayer-hls:$exoPlayerVersion" + implementation "com.google.android.exoplayer:exoplayer-dash:$exoPlayerVersion" + implementation "com.google.android.exoplayer:exoplayer-smoothstreaming:$exoPlayerVersion" + implementation "com.google.android.exoplayer:exoplayer-ui:$exoPlayerVersion" + implementation "com.google.android.exoplayer:extension-mediasession:$exoPlayerVersion" + implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion" + implementation "androidx.lifecycle:lifecycle-common:$lifecycleVersion" + implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion" + implementation "androidx.annotation:annotation:$annotationVersion" + implementation "androidx.work:work-runtime:$workVersion" } } +dependencies { + implementation "androidx.core:core-ktx:$coreVersion" + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion" +} +repositories { + mavenCentral() +} diff --git a/android/src/main/java/com/jhomlala/better_player/BetterPlayer.java b/android/src/main/java/com/jhomlala/better_player/BetterPlayer.java deleted file mode 100644 index f37b86c49..000000000 --- a/android/src/main/java/com/jhomlala/better_player/BetterPlayer.java +++ /dev/null @@ -1,964 +0,0 @@ -package com.jhomlala.better_player; - -import android.app.NotificationChannel; -import android.app.NotificationManager; -import android.app.PendingIntent; -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import android.net.Uri; -import android.os.Build; -import android.os.Handler; -import android.support.v4.media.MediaMetadataCompat; -import android.support.v4.media.session.MediaSessionCompat; -import android.support.v4.media.session.PlaybackStateCompat; -import android.util.Log; -import android.view.Surface; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.lifecycle.Observer; -import androidx.media.session.MediaButtonReceiver; -import androidx.work.Data; -import androidx.work.OneTimeWorkRequest; -import androidx.work.WorkInfo; -import androidx.work.WorkManager; - -import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.ControlDispatcher; -import com.google.android.exoplayer2.DefaultLoadControl; -import com.google.android.exoplayer2.ExoPlaybackException; -import com.google.android.exoplayer2.ExoPlayer; -import com.google.android.exoplayer2.Format; -import com.google.android.exoplayer2.LoadControl; -import com.google.android.exoplayer2.MediaItem; -import com.google.android.exoplayer2.PlaybackParameters; -import com.google.android.exoplayer2.Player; -import com.google.android.exoplayer2.SimpleExoPlayer; -import com.google.android.exoplayer2.Timeline; -import com.google.android.exoplayer2.audio.AudioAttributes; -import com.google.android.exoplayer2.drm.DefaultDrmSessionManager; -import com.google.android.exoplayer2.drm.DrmSessionManager; -import com.google.android.exoplayer2.drm.DummyExoMediaDrm; -import com.google.android.exoplayer2.drm.FrameworkMediaDrm; -import com.google.android.exoplayer2.drm.HttpMediaDrmCallback; -import com.google.android.exoplayer2.drm.LocalMediaDrmCallback; -import com.google.android.exoplayer2.drm.UnsupportedDrmException; -import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector; -import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory; -import com.google.android.exoplayer2.source.ClippingMediaSource; -import com.google.android.exoplayer2.source.MediaSource; -import com.google.android.exoplayer2.source.ProgressiveMediaSource; -import com.google.android.exoplayer2.source.TrackGroup; -import com.google.android.exoplayer2.source.TrackGroupArray; -import com.google.android.exoplayer2.source.dash.DashMediaSource; -import com.google.android.exoplayer2.source.dash.DefaultDashChunkSource; -import com.google.android.exoplayer2.source.hls.HlsMediaSource; -import com.google.android.exoplayer2.source.smoothstreaming.DefaultSsChunkSource; -import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource; -import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; -import com.google.android.exoplayer2.trackselection.MappingTrackSelector; -import com.google.android.exoplayer2.ui.PlayerNotificationManager; -import com.google.android.exoplayer2.upstream.DataSource; -import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; -import com.google.android.exoplayer2.upstream.DefaultHttpDataSource; -import com.google.android.exoplayer2.util.Util; - -import java.io.File; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.UUID; - -import io.flutter.plugin.common.EventChannel; -import io.flutter.plugin.common.MethodChannel.Result; -import io.flutter.view.TextureRegistry; - -import static com.google.android.exoplayer2.Player.REPEAT_MODE_ALL; -import static com.google.android.exoplayer2.Player.REPEAT_MODE_OFF; -import static com.jhomlala.better_player.DataSourceUtils.getDataSourceFactory; -import static com.jhomlala.better_player.DataSourceUtils.getUserAgent; - -final class BetterPlayer { - private static final String TAG = "BetterPlayer"; - private static final String FORMAT_SS = "ss"; - private static final String FORMAT_DASH = "dash"; - private static final String FORMAT_HLS = "hls"; - private static final String FORMAT_OTHER = "other"; - private static final String DEFAULT_NOTIFICATION_CHANNEL = "BETTER_PLAYER_NOTIFICATION"; - private static final int NOTIFICATION_ID = 20772077; - - private final SimpleExoPlayer exoPlayer; - private final TextureRegistry.SurfaceTextureEntry textureEntry; - private final QueuingEventSink eventSink = new QueuingEventSink(); - private final EventChannel eventChannel; - private final DefaultTrackSelector trackSelector; - private final LoadControl loadControl; - - private boolean isInitialized = false; - private Surface surface; - private String key; - private PlayerNotificationManager playerNotificationManager; - private Handler refreshHandler; - private Runnable refreshRunnable; - private ExoPlayer.Listener exoPlayerEventListener; - private Bitmap bitmap; - private MediaSessionCompat mediaSession; - private DrmSessionManager drmSessionManager; - private WorkManager workManager; - private HashMap<UUID, Observer<WorkInfo>> workerObserverMap; - private CustomDefaultLoadControl customDefaultLoadControl; - private long lastSendBufferedPosition = 0L; - - - BetterPlayer( - Context context, - EventChannel eventChannel, - TextureRegistry.SurfaceTextureEntry textureEntry, - CustomDefaultLoadControl customDefaultLoadControl, - Result result) { - com.google.android.exoplayer2.util.Log.setLogLevel( - com.google.android.exoplayer2.util.Log.LOG_LEVEL_ERROR); - this.eventChannel = eventChannel; - this.textureEntry = textureEntry; - trackSelector = new DefaultTrackSelector(context); - - this.customDefaultLoadControl = customDefaultLoadControl != null ? - customDefaultLoadControl : new CustomDefaultLoadControl(); - DefaultLoadControl.Builder loadBuilder = new DefaultLoadControl.Builder(); - loadBuilder.setBufferDurationsMs( - this.customDefaultLoadControl.minBufferMs, - this.customDefaultLoadControl.maxBufferMs, - this.customDefaultLoadControl.bufferForPlaybackMs, - this.customDefaultLoadControl.bufferForPlaybackAfterRebufferMs); - loadControl = loadBuilder.build(); - - exoPlayer = new SimpleExoPlayer.Builder(context) - .setTrackSelector(trackSelector) - .setLoadControl(loadControl) - .build(); - workManager = WorkManager.getInstance(context); - workerObserverMap = new HashMap<>(); - - setupVideoPlayer(eventChannel, textureEntry, result); - } - - void setDataSource( - Context context, String key, String dataSource, String formatHint, Result result, - Map<String, String> headers, boolean useCache, long maxCacheSize, long maxCacheFileSize, - long overriddenDuration, String licenseUrl, Map<String, String> drmHeaders, - String cacheKey, String clearKey) { - this.key = key; - isInitialized = false; - - Uri uri = Uri.parse(dataSource); - DataSource.Factory dataSourceFactory; - - String userAgent = getUserAgent(headers); - - if (licenseUrl != null && !licenseUrl.isEmpty()) { - HttpMediaDrmCallback httpMediaDrmCallback = - new HttpMediaDrmCallback(licenseUrl, new DefaultHttpDataSource.Factory()); - - if (drmHeaders != null) { - for (Map.Entry<String, String> entry : drmHeaders.entrySet()) { - httpMediaDrmCallback.setKeyRequestProperty(entry.getKey(), entry.getValue()); - } - } - - if (Util.SDK_INT < 18) { - Log.e(TAG, "Protected content not supported on API levels below 18"); - drmSessionManager = null; - } else { - UUID drmSchemeUuid = Util.getDrmUuid("widevine"); - if (drmSchemeUuid != null) { - drmSessionManager = - new DefaultDrmSessionManager.Builder() - .setUuidAndExoMediaDrmProvider(drmSchemeUuid, - uuid -> { - try { - FrameworkMediaDrm mediaDrm = FrameworkMediaDrm.newInstance(uuid); - // Force L3. - mediaDrm.setPropertyString("securityLevel", "L3"); - return mediaDrm; - } catch (UnsupportedDrmException e) { - return new DummyExoMediaDrm(); - } - }) - .setMultiSession(false) - .build(httpMediaDrmCallback); - } - } - } else if (clearKey != null && !clearKey.isEmpty()) { - if (Util.SDK_INT < 18) { - Log.e(TAG, "Protected content not supported on API levels below 18"); - drmSessionManager = null; - } else { - drmSessionManager = new DefaultDrmSessionManager.Builder() - .setUuidAndExoMediaDrmProvider(C.CLEARKEY_UUID, FrameworkMediaDrm.DEFAULT_PROVIDER). - build(new LocalMediaDrmCallback(clearKey.getBytes())); - } - - } else { - drmSessionManager = null; - } - - if (DataSourceUtils.isHTTP(uri)) { - dataSourceFactory = getDataSourceFactory(userAgent, headers); - - if (useCache && maxCacheSize > 0 && maxCacheFileSize > 0) { - dataSourceFactory = - new CacheDataSourceFactory(context, maxCacheSize, maxCacheFileSize, dataSourceFactory); - } - } else { - dataSourceFactory = new DefaultDataSourceFactory(context, userAgent); - } - - MediaSource mediaSource = buildMediaSource(uri, dataSourceFactory, formatHint, cacheKey, context); - if (overriddenDuration != 0) { - ClippingMediaSource clippingMediaSource = new ClippingMediaSource(mediaSource, 0, overriddenDuration * 1000); - exoPlayer.setMediaSource(clippingMediaSource); - } else { - exoPlayer.setMediaSource(mediaSource); - } - exoPlayer.prepare(); - - result.success(null); - } - - - public void setupPlayerNotification(Context context, String title, String author, - String imageUrl, String notificationChannelName, - String activityName) { - - PlayerNotificationManager.MediaDescriptionAdapter mediaDescriptionAdapter - = new PlayerNotificationManager.MediaDescriptionAdapter() { - @NonNull - @Override - public String getCurrentContentTitle(@NonNull Player player) { - return title; - } - - @Nullable - @Override - public PendingIntent createCurrentContentIntent(@NonNull Player player) { - - final String packageName = context.getApplicationContext().getPackageName(); - Intent notificationIntent = new Intent(); - notificationIntent.setClassName(packageName, - packageName + "." + activityName); - notificationIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP - | Intent.FLAG_ACTIVITY_SINGLE_TOP); - return PendingIntent.getActivity(context, 0, - notificationIntent, 0); - } - - @Nullable - @Override - public String getCurrentContentText(@NonNull Player player) { - return author; - } - - @Nullable - @Override - public Bitmap getCurrentLargeIcon(@NonNull Player player, - @NonNull PlayerNotificationManager.BitmapCallback callback) { - if (imageUrl == null) { - return null; - } - if (bitmap != null) { - return bitmap; - } - - - OneTimeWorkRequest imageWorkRequest = new OneTimeWorkRequest.Builder(ImageWorker.class) - .addTag(imageUrl) - .setInputData( - new Data.Builder() - .putString(BetterPlayerPlugin.URL_PARAMETER, imageUrl) - .build()) - .build(); - - workManager.enqueue(imageWorkRequest); - - Observer<WorkInfo> workInfoObserver = workInfo -> { - try { - if (workInfo != null) { - WorkInfo.State state = workInfo.getState(); - if (state == WorkInfo.State.SUCCEEDED) { - - Data outputData = workInfo.getOutputData(); - String filePath = outputData.getString(BetterPlayerPlugin.FILE_PATH_PARAMETER); - //Bitmap here is already processed and it's very small, so it won't - //break anything. - bitmap = BitmapFactory.decodeFile(filePath); - callback.onBitmap(bitmap); - - } - if (state == WorkInfo.State.SUCCEEDED - || state == WorkInfo.State.CANCELLED - || state == WorkInfo.State.FAILED) { - final UUID uuid = imageWorkRequest.getId(); - Observer<WorkInfo> observer = workerObserverMap.remove(uuid); - if (observer != null) { - workManager.getWorkInfoByIdLiveData(uuid).removeObserver(observer); - } - } - } - - - } catch (Exception exception) { - Log.e(TAG, "Image select error: " + exception); - } - }; - - final UUID workerUuid = imageWorkRequest.getId(); - workManager.getWorkInfoByIdLiveData(workerUuid) - .observeForever(workInfoObserver); - workerObserverMap.put(workerUuid, workInfoObserver); - - return null; - } - }; - - String playerNotificationChannelName = notificationChannelName; - if (notificationChannelName == null) { - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { - int importance = NotificationManager.IMPORTANCE_LOW; - NotificationChannel channel = new NotificationChannel(DEFAULT_NOTIFICATION_CHANNEL, - DEFAULT_NOTIFICATION_CHANNEL, importance); - channel.setDescription(DEFAULT_NOTIFICATION_CHANNEL); - NotificationManager notificationManager = context.getSystemService(NotificationManager.class); - notificationManager.createNotificationChannel(channel); - playerNotificationChannelName = DEFAULT_NOTIFICATION_CHANNEL; - } - } - - playerNotificationManager = new PlayerNotificationManager.Builder(context, - NOTIFICATION_ID, - playerNotificationChannelName, - mediaDescriptionAdapter).build(); - playerNotificationManager.setPlayer(exoPlayer); - playerNotificationManager.setUseNextAction(false); - playerNotificationManager.setUsePreviousAction(false); - playerNotificationManager.setUseStopAction(false); - - - MediaSessionCompat mediaSession = setupMediaSession(context, false); - playerNotificationManager.setMediaSessionToken(mediaSession.getSessionToken()); - - - playerNotificationManager.setControlDispatcher(setupControlDispatcher()); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - refreshHandler = new Handler(); - refreshRunnable = () -> { - PlaybackStateCompat playbackState; - if (exoPlayer.getPlayWhenReady()) { - playbackState = new PlaybackStateCompat.Builder() - .setActions(PlaybackStateCompat.ACTION_SEEK_TO) - .setState(PlaybackStateCompat.STATE_PAUSED, getPosition(), 1.0f) - .build(); - } else { - playbackState = new PlaybackStateCompat.Builder() - .setActions(PlaybackStateCompat.ACTION_SEEK_TO) - .setState(PlaybackStateCompat.STATE_PLAYING, getPosition(), 1.0f) - .build(); - } - - mediaSession.setPlaybackState(playbackState); - refreshHandler.postDelayed(refreshRunnable, 1000); - }; - refreshHandler.postDelayed(refreshRunnable, 0); - } - - exoPlayerEventListener = new Player.Listener() { - @Override - public void onPlaybackStateChanged(int playbackState) { - mediaSession.setMetadata(new MediaMetadataCompat.Builder() - .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, getDuration()) - .build()); - } - }; - - exoPlayer.addListener(exoPlayerEventListener); - exoPlayer.seekTo(0); - } - - - private ControlDispatcher setupControlDispatcher() { - return new ControlDispatcher() { - @Override - public boolean dispatchPrepare(Player player) { - return false; - } - - @Override - public boolean dispatchSetPlayWhenReady(Player player, boolean playWhenReady) { - if (player.getPlayWhenReady()) { - sendEvent("pause"); - } else { - sendEvent("play"); - } - return true; - } - - @Override - public boolean dispatchSeekTo(Player player, int windowIndex, long positionMs) { - sendSeekToEvent(positionMs); - return true; - } - - @Override - public boolean dispatchPrevious(Player player) { - return false; - } - - @Override - public boolean dispatchNext(Player player) { - return false; - } - - @Override - public boolean dispatchRewind(Player player) { - sendSeekToEvent(player.getCurrentPosition() - 5000); - return false; - } - - @Override - public boolean dispatchFastForward(Player player) { - sendSeekToEvent(player.getCurrentPosition() + 5000); - return true; - } - - @Override - public boolean dispatchSetRepeatMode(Player player, int repeatMode) { - return false; - } - - @Override - public boolean dispatchSetShuffleModeEnabled(Player player, boolean shuffleModeEnabled) { - return false; - } - - @Override - public boolean dispatchStop(Player player, boolean reset) { - return false; - } - - @Override - public boolean dispatchSetPlaybackParameters(Player player, PlaybackParameters playbackParameters) { - return false; - } - - @Override - public boolean isRewindEnabled() { - return true; - } - - @Override - public boolean isFastForwardEnabled() { - return true; - } - }; - } - - - public void disposeRemoteNotifications() { - if (exoPlayerEventListener != null) { - exoPlayer.removeListener(exoPlayerEventListener); - } - if (refreshHandler != null) { - refreshHandler.removeCallbacksAndMessages(null); - refreshHandler = null; - refreshRunnable = null; - } - if (playerNotificationManager != null) { - playerNotificationManager.setPlayer(null); - } - bitmap = null; - } - - - private MediaSource buildMediaSource( - Uri uri, DataSource.Factory mediaDataSourceFactory, String formatHint, String cacheKey, - Context context) { - int type; - if (formatHint == null) { - String lastPathSegment = uri.getLastPathSegment(); - if (lastPathSegment == null) { - lastPathSegment = ""; - } - type = Util.inferContentType(lastPathSegment); - } else { - switch (formatHint) { - case FORMAT_SS: - type = C.TYPE_SS; - break; - case FORMAT_DASH: - type = C.TYPE_DASH; - break; - case FORMAT_HLS: - type = C.TYPE_HLS; - break; - case FORMAT_OTHER: - type = C.TYPE_OTHER; - break; - default: - type = -1; - break; - } - } - MediaItem.Builder mediaItemBuilder = new MediaItem.Builder(); - mediaItemBuilder.setUri(uri); - if (cacheKey != null && cacheKey.length() > 0) { - mediaItemBuilder.setCustomCacheKey(cacheKey); - } - MediaItem mediaItem = mediaItemBuilder.build(); - switch (type) { - - case C.TYPE_SS: - return new SsMediaSource.Factory( - new DefaultSsChunkSource.Factory(mediaDataSourceFactory), - new DefaultDataSourceFactory(context, null, mediaDataSourceFactory)) - .setDrmSessionManager(drmSessionManager) - .createMediaSource(mediaItem); - case C.TYPE_DASH: - return new DashMediaSource.Factory( - new DefaultDashChunkSource.Factory(mediaDataSourceFactory), - new DefaultDataSourceFactory(context, null, mediaDataSourceFactory)) - .setDrmSessionManager(drmSessionManager) - .createMediaSource(mediaItem); - case C.TYPE_HLS: - return new HlsMediaSource.Factory(mediaDataSourceFactory) - .setDrmSessionManager(drmSessionManager) - .createMediaSource(mediaItem); - case C.TYPE_OTHER: - return new ProgressiveMediaSource.Factory(mediaDataSourceFactory, - new DefaultExtractorsFactory()) - .setDrmSessionManager(drmSessionManager) - .createMediaSource(mediaItem); - default: { - throw new IllegalStateException("Unsupported type: " + type); - } - } - } - - private void setupVideoPlayer( - EventChannel eventChannel, TextureRegistry.SurfaceTextureEntry textureEntry, Result result) { - - eventChannel.setStreamHandler( - new EventChannel.StreamHandler() { - @Override - public void onListen(Object o, EventChannel.EventSink sink) { - eventSink.setDelegate(sink); - } - - @Override - public void onCancel(Object o) { - eventSink.setDelegate(null); - } - }); - - surface = new Surface(textureEntry.surfaceTexture()); - exoPlayer.setVideoSurface(surface); - setAudioAttributes(exoPlayer, true); - - exoPlayer.addListener(new Player.Listener() { - @Override - public void onPlaybackStateChanged(int playbackState) { - - if (playbackState == Player.STATE_BUFFERING) { - sendBufferingUpdate(true); - Map<String, Object> event = new HashMap<>(); - event.put("event", "bufferingStart"); - eventSink.success(event); - } else if (playbackState == Player.STATE_READY) { - if (!isInitialized) { - isInitialized = true; - sendInitialized(); - } - - Map<String, Object> event = new HashMap<>(); - event.put("event", "bufferingEnd"); - eventSink.success(event); - - } else if (playbackState == Player.STATE_ENDED) { - Map<String, Object> event = new HashMap<>(); - event.put("event", "completed"); - event.put("key", key); - eventSink.success(event); - } - } - - @Override - public void onPlayerError(final ExoPlaybackException error) { - eventSink.error("VideoError", "Video player had error " + error, null); - } - }); - - Map<String, Object> reply = new HashMap<>(); - reply.put("textureId", textureEntry.id()); - result.success(reply); - } - - void sendBufferingUpdate(boolean isFromBufferingStart) { - long bufferedPosition = exoPlayer.getBufferedPosition(); - if (isFromBufferingStart || bufferedPosition != lastSendBufferedPosition) { - Map<String, Object> event = new HashMap<>(); - event.put("event", "bufferingUpdate"); - List<? extends Number> range = Arrays.asList(0, bufferedPosition); - // iOS supports a list of buffered ranges, so here is a list with a single range. - event.put("values", Collections.singletonList(range)); - eventSink.success(event); - lastSendBufferedPosition = bufferedPosition; - } - } - - private void setAudioAttributes(SimpleExoPlayer exoPlayer, Boolean mixWithOthers) { - ExoPlayer.AudioComponent audioComponent = exoPlayer.getAudioComponent(); - if (audioComponent == null) { - return; - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - - audioComponent.setAudioAttributes( - new AudioAttributes.Builder().setContentType(C.CONTENT_TYPE_MOVIE).build(), !mixWithOthers); - } else { - audioComponent.setAudioAttributes( - new AudioAttributes.Builder().setContentType(C.CONTENT_TYPE_MUSIC).build(), !mixWithOthers); - } - } - - void play() { - exoPlayer.setPlayWhenReady(true); - } - - void pause() { - exoPlayer.setPlayWhenReady(false); - } - - void setLooping(boolean value) { - exoPlayer.setRepeatMode(value ? REPEAT_MODE_ALL : REPEAT_MODE_OFF); - } - - void setVolume(double value) { - float bracketedValue = (float) Math.max(0.0, Math.min(1.0, value)); - exoPlayer.setVolume(bracketedValue); - } - - void setSpeed(double value) { - float bracketedValue = (float) value; - PlaybackParameters playbackParameters = new PlaybackParameters(bracketedValue); - exoPlayer.setPlaybackParameters(playbackParameters); - } - - void setTrackParameters(int width, int height, int bitrate) { - DefaultTrackSelector.ParametersBuilder parametersBuilder = trackSelector.buildUponParameters(); - if (width != 0 && height != 0) { - parametersBuilder.setMaxVideoSize(width, height); - } - if (bitrate != 0) { - parametersBuilder.setMaxVideoBitrate(bitrate); - } - if (width == 0 && height == 0 && bitrate == 0) { - parametersBuilder.clearVideoSizeConstraints(); - parametersBuilder.setMaxVideoBitrate(Integer.MAX_VALUE); - } - - trackSelector.setParameters(parametersBuilder); - } - - void seekTo(int location) { - exoPlayer.seekTo(location); - } - - long getPosition() { - return exoPlayer.getCurrentPosition(); - } - - long getAbsolutePosition() { - Timeline timeline = exoPlayer.getCurrentTimeline(); - if (!timeline.isEmpty()) { - long windowStartTimeMs = timeline.getWindow(0, new Timeline.Window()).windowStartTimeMs; - long pos = exoPlayer.getCurrentPosition(); - return (windowStartTimeMs + pos); - } - return exoPlayer.getCurrentPosition(); - } - - @SuppressWarnings("SuspiciousNameCombination") - private void sendInitialized() { - if (isInitialized) { - Map<String, Object> event = new HashMap<>(); - event.put("event", "initialized"); - event.put("key", key); - event.put("duration", getDuration()); - - if (exoPlayer.getVideoFormat() != null) { - Format videoFormat = exoPlayer.getVideoFormat(); - int width = videoFormat.width; - int height = videoFormat.height; - int rotationDegrees = videoFormat.rotationDegrees; - // Switch the width/height if video was taken in portrait mode - if (rotationDegrees == 90 || rotationDegrees == 270) { - width = exoPlayer.getVideoFormat().height; - height = exoPlayer.getVideoFormat().width; - } - event.put("width", width); - event.put("height", height); - } - eventSink.success(event); - } - } - - private long getDuration() { - return exoPlayer.getDuration(); - } - - /** - * Create media session which will be used in notifications, pip mode. - * - * @param context - android context - * @param setupControlDispatcher - should add control dispatcher to created MediaSession - * @return - configured MediaSession instance - */ - public MediaSessionCompat setupMediaSession(Context context, boolean setupControlDispatcher) { - if (mediaSession != null) { - mediaSession.release(); - } - ComponentName mediaButtonReceiver = new ComponentName(context, MediaButtonReceiver.class); - MediaSessionCompat mediaSession = new MediaSessionCompat(context, "BetterPlayer", mediaButtonReceiver, null); - mediaSession.setCallback(new MediaSessionCompat.Callback() { - @Override - public void onSeekTo(long pos) { - sendSeekToEvent(pos); - super.onSeekTo(pos); - } - }); - - mediaSession.setActive(true); - MediaSessionConnector mediaSessionConnector = - new MediaSessionConnector(mediaSession); - if (setupControlDispatcher) { - mediaSessionConnector.setControlDispatcher(setupControlDispatcher()); - } - mediaSessionConnector.setPlayer(exoPlayer); - - Intent mediaButtonIntent = new Intent(Intent.ACTION_MEDIA_BUTTON); - mediaButtonIntent.setClass(context, MediaButtonReceiver.class); - PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, mediaButtonIntent, 0); - mediaSession.setMediaButtonReceiver(pendingIntent); - - - this.mediaSession = mediaSession; - return mediaSession; - } - - public void onPictureInPictureStatusChanged(boolean inPip) { - Map<String, Object> event = new HashMap<>(); - event.put("event", inPip ? "pipStart" : "pipStop"); - eventSink.success(event); - } - - public void disposeMediaSession() { - if (mediaSession != null) { - mediaSession.release(); - } - mediaSession = null; - } - - private void sendEvent(String eventType) { - Map<String, Object> event = new HashMap<>(); - event.put("event", eventType); - eventSink.success(event); - } - - void setAudioTrack(String name, Integer index) { - try { - MappingTrackSelector.MappedTrackInfo mappedTrackInfo = - trackSelector.getCurrentMappedTrackInfo(); - - if (mappedTrackInfo != null) { - for (int rendererIndex = 0; rendererIndex < mappedTrackInfo.getRendererCount(); - rendererIndex++) { - if (mappedTrackInfo.getRendererType(rendererIndex) != C.TRACK_TYPE_AUDIO) { - continue; - } - TrackGroupArray trackGroupArray = mappedTrackInfo.getTrackGroups(rendererIndex); - boolean hasElementWithoutLabel = false; - boolean hasStrangeAudioTrack = false; - for (int groupIndex = 0; groupIndex < trackGroupArray.length; groupIndex++) { - TrackGroup group = trackGroupArray.get(groupIndex); - for (int groupElementIndex = 0; groupElementIndex < group.length; groupElementIndex++) { - Format format = group.getFormat(groupElementIndex); - - if (format.label == null) { - hasElementWithoutLabel = true; - } - - if (format.id != null && format.id.equals("1/15")) { - hasStrangeAudioTrack = true; - } - } - } - - for (int groupIndex = 0; groupIndex < trackGroupArray.length; groupIndex++) { - TrackGroup group = trackGroupArray.get(groupIndex); - for (int groupElementIndex = 0; groupElementIndex < group.length; groupElementIndex++) { - String label = group.getFormat(groupElementIndex).label; - - if (name.equals(label) && index == groupIndex) { - setAudioTrack(rendererIndex, groupIndex, groupElementIndex); - return; - } - - ///Fallback option - if (!hasStrangeAudioTrack && hasElementWithoutLabel && index == groupIndex) { - setAudioTrack(rendererIndex, groupIndex, groupElementIndex); - return; - } - ///Fallback option - if (hasStrangeAudioTrack && name.equals(label)) { - setAudioTrack(rendererIndex, groupIndex, groupElementIndex); - return; - } - } - } - } - } - } catch (Exception exception) { - Log.e(TAG, "setAudioTrack failed" + exception.toString()); - } - } - - private void setAudioTrack(int rendererIndex, int groupIndex, int groupElementIndex) { - MappingTrackSelector.MappedTrackInfo mappedTrackInfo = - trackSelector.getCurrentMappedTrackInfo(); - if (mappedTrackInfo != null) { - DefaultTrackSelector.ParametersBuilder builder = - trackSelector.getParameters().buildUpon(); - builder.clearSelectionOverrides(rendererIndex) - .setRendererDisabled(rendererIndex, false); - int[] tracks = {groupElementIndex}; - DefaultTrackSelector.SelectionOverride override = - new DefaultTrackSelector.SelectionOverride(groupIndex, tracks); - builder.setSelectionOverride(rendererIndex, - mappedTrackInfo.getTrackGroups(rendererIndex), override); - trackSelector.setParameters(builder); - } - } - - private void sendSeekToEvent(long positionMs) { - exoPlayer.seekTo(positionMs); - Map<String, Object> event = new HashMap<>(); - event.put("event", "seek"); - event.put("position", positionMs); - eventSink.success(event); - } - - public void setMixWithOthers(Boolean mixWithOthers) { - setAudioAttributes(exoPlayer, mixWithOthers); - } - - //Clear cache without accessing BetterPlayerCache. - @SuppressWarnings("ResultOfMethodCallIgnored") - public static void clearCache(Context context, Result result) { - try { - File file = new File(context.getCacheDir(), "betterPlayerCache"); - deleteDirectory(file); - result.success(null); - } catch (Exception exception) { - Log.e(TAG, exception.toString()); - result.error("", "", ""); - } - } - - private static void deleteDirectory(File file) { - if (file.isDirectory()) { - File[] entries = file.listFiles(); - if (entries != null) { - for (File entry : entries) { - deleteDirectory(entry); - } - } - } - if (!file.delete()) { - Log.e(TAG, "Failed to delete cache dir."); - } - } - - - //Start pre cache of video. Invoke work manager job and start caching in background. - static void preCache(Context context, String dataSource, long preCacheSize, - long maxCacheSize, long maxCacheFileSize, Map<String, String> headers, - String cacheKey, Result result) { - Data.Builder dataBuilder = new Data.Builder() - .putString(BetterPlayerPlugin.URL_PARAMETER, dataSource) - .putLong(BetterPlayerPlugin.PRE_CACHE_SIZE_PARAMETER, preCacheSize) - .putLong(BetterPlayerPlugin.MAX_CACHE_SIZE_PARAMETER, maxCacheSize) - .putLong(BetterPlayerPlugin.MAX_CACHE_FILE_SIZE_PARAMETER, maxCacheFileSize); - - if (cacheKey != null) { - dataBuilder.putString(BetterPlayerPlugin.CACHE_KEY_PARAMETER, cacheKey); - } - for (String headerKey : headers.keySet()) { - dataBuilder.putString(BetterPlayerPlugin.HEADER_PARAMETER + headerKey, headers.get(headerKey)); - } - - OneTimeWorkRequest cacheWorkRequest = new OneTimeWorkRequest.Builder(CacheWorker.class) - .addTag(dataSource) - .setInputData(dataBuilder.build()).build(); - WorkManager.getInstance(context).enqueue(cacheWorkRequest); - result.success(null); - } - - //Stop pre cache of video with given url. If there's no work manager job for given url, then - //it will be ignored. - static void stopPreCache(Context context, String url, Result result) { - WorkManager.getInstance(context).cancelAllWorkByTag(url); - result.success(null); - } - - void dispose() { - disposeMediaSession(); - disposeRemoteNotifications(); - if (isInitialized) { - exoPlayer.stop(); - } - textureEntry.release(); - eventChannel.setStreamHandler(null); - if (surface != null) { - surface.release(); - } - if (exoPlayer != null) { - exoPlayer.release(); - } - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - BetterPlayer that = (BetterPlayer) o; - - if (exoPlayer != null ? !exoPlayer.equals(that.exoPlayer) : that.exoPlayer != null) - return false; - return surface != null ? surface.equals(that.surface) : that.surface == null; - } - - @Override - public int hashCode() { - int result = exoPlayer != null ? exoPlayer.hashCode() : 0; - result = 31 * result + (surface != null ? surface.hashCode() : 0); - return result; - } - -} - - - diff --git a/android/src/main/java/com/jhomlala/better_player/BetterPlayerCache.java b/android/src/main/java/com/jhomlala/better_player/BetterPlayerCache.java deleted file mode 100644 index c4af52bd3..000000000 --- a/android/src/main/java/com/jhomlala/better_player/BetterPlayerCache.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.jhomlala.better_player; - -import android.content.Context; -import android.util.Log; - -import com.google.android.exoplayer2.database.ExoDatabaseProvider; -import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor; -import com.google.android.exoplayer2.upstream.cache.SimpleCache; - -import java.io.File; - -public class BetterPlayerCache { - private static volatile SimpleCache instance; - - public static SimpleCache createCache(Context context, long cacheFileSize) { - if (instance == null) { - synchronized (BetterPlayerCache.class) { - if (instance == null) { - instance = new SimpleCache( - new File(context.getCacheDir(), "betterPlayerCache"), - new LeastRecentlyUsedCacheEvictor(cacheFileSize), - new ExoDatabaseProvider(context)); - } - } - } - return instance; - } - - public static void releaseCache() { - try { - if (instance != null) { - instance.release(); - instance = null; - } - } catch (Exception exception) { - Log.e("BetterPlayerCache", exception.toString()); - } - } -} diff --git a/android/src/main/java/com/jhomlala/better_player/BetterPlayerPlugin.java b/android/src/main/java/com/jhomlala/better_player/BetterPlayerPlugin.java deleted file mode 100644 index 885700a78..000000000 --- a/android/src/main/java/com/jhomlala/better_player/BetterPlayerPlugin.java +++ /dev/null @@ -1,566 +0,0 @@ -// Copyright 2017 The Chromium Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -package com.jhomlala.better_player; - -import android.app.Activity; -import android.app.PictureInPictureParams; -import android.content.Context; -import android.content.pm.PackageManager; -import android.os.Build; -import android.os.Handler; -import android.util.Log; -import android.util.LongSparseArray; - -import androidx.annotation.NonNull; - -import java.util.HashMap; -import java.util.Map; - -import io.flutter.embedding.engine.loader.FlutterLoader; -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.MethodCall; -import io.flutter.plugin.common.MethodChannel; -import io.flutter.plugin.common.MethodChannel.MethodCallHandler; -import io.flutter.plugin.common.MethodChannel.Result; -import io.flutter.view.TextureRegistry; - -/** - * Android platform implementation of the VideoPlayerPlugin. - */ -public class BetterPlayerPlugin implements FlutterPlugin, ActivityAware, MethodCallHandler { - private static final String TAG = "BetterPlayerPlugin"; - private static final String CHANNEL = "better_player_channel"; - private static final String EVENTS_CHANNEL = "better_player_channel/videoEvents"; - private static final String DATA_SOURCE_PARAMETER = "dataSource"; - private static final String KEY_PARAMETER = "key"; - private static final String HEADERS_PARAMETER = "headers"; - private static final String USE_CACHE_PARAMETER = "useCache"; - - - private static final String ASSET_PARAMETER = "asset"; - private static final String PACKAGE_PARAMETER = "package"; - private static final String URI_PARAMETER = "uri"; - private static final String FORMAT_HINT_PARAMETER = "formatHint"; - private static final String TEXTURE_ID_PARAMETER = "textureId"; - private static final String LOOPING_PARAMETER = "looping"; - private static final String VOLUME_PARAMETER = "volume"; - private static final String LOCATION_PARAMETER = "location"; - private static final String SPEED_PARAMETER = "speed"; - private static final String WIDTH_PARAMETER = "width"; - private static final String HEIGHT_PARAMETER = "height"; - private static final String BITRATE_PARAMETER = "bitrate"; - private static final String SHOW_NOTIFICATION_PARAMETER = "showNotification"; - private static final String TITLE_PARAMETER = "title"; - private static final String AUTHOR_PARAMETER = "author"; - private static final String IMAGE_URL_PARAMETER = "imageUrl"; - private static final String NOTIFICATION_CHANNEL_NAME_PARAMETER = "notificationChannelName"; - private static final String OVERRIDDEN_DURATION_PARAMETER = "overriddenDuration"; - private static final String NAME_PARAMETER = "name"; - private static final String INDEX_PARAMETER = "index"; - private static final String LICENSE_URL_PARAMETER = "licenseUrl"; - private static final String DRM_HEADERS_PARAMETER = "drmHeaders"; - private static final String DRM_CLEARKEY_PARAMETER = "clearKey"; - private static final String MIX_WITH_OTHERS_PARAMETER = "mixWithOthers"; - public static final String URL_PARAMETER = "url"; - public static final String PRE_CACHE_SIZE_PARAMETER = "preCacheSize"; - public static final String MAX_CACHE_SIZE_PARAMETER = "maxCacheSize"; - public static final String MAX_CACHE_FILE_SIZE_PARAMETER = "maxCacheFileSize"; - public static final String HEADER_PARAMETER = "header_"; - public static final String FILE_PATH_PARAMETER = "filePath"; - public static final String ACTIVITY_NAME_PARAMETER = "activityName"; - public static final String MIN_BUFFER_MS = "minBufferMs"; - public static final String MAX_BUFFER_MS = "maxBufferMs"; - public static final String BUFFER_FOR_PLAYBACK_MS = "bufferForPlaybackMs"; - public static final String BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS = "bufferForPlaybackAfterRebufferMs"; - public static final String CACHE_KEY_PARAMETER = "cacheKey"; - - - private static final String INIT_METHOD = "init"; - private static final String CREATE_METHOD = "create"; - private static final String SET_DATA_SOURCE_METHOD = "setDataSource"; - private static final String SET_LOOPING_METHOD = "setLooping"; - private static final String SET_VOLUME_METHOD = "setVolume"; - private static final String PLAY_METHOD = "play"; - private static final String PAUSE_METHOD = "pause"; - private static final String SEEK_TO_METHOD = "seekTo"; - private static final String POSITION_METHOD = "position"; - private static final String ABSOLUTE_POSITION_METHOD = "absolutePosition"; - private static final String SET_SPEED_METHOD = "setSpeed"; - private static final String SET_TRACK_PARAMETERS_METHOD = "setTrackParameters"; - private static final String SET_AUDIO_TRACK_METHOD = "setAudioTrack"; - private static final String ENABLE_PICTURE_IN_PICTURE_METHOD = "enablePictureInPicture"; - private static final String DISABLE_PICTURE_IN_PICTURE_METHOD = "disablePictureInPicture"; - private static final String IS_PICTURE_IN_PICTURE_SUPPORTED_METHOD = "isPictureInPictureSupported"; - private static final String SET_MIX_WITH_OTHERS_METHOD = "setMixWithOthers"; - private static final String CLEAR_CACHE_METHOD = "clearCache"; - private static final String DISPOSE_METHOD = "dispose"; - private static final String PRE_CACHE_METHOD = "preCache"; - private static final String STOP_PRE_CACHE_METHOD = "stopPreCache"; - - private final LongSparseArray<BetterPlayer> videoPlayers = new LongSparseArray<>(); - private final LongSparseArray<Map<String, Object>> dataSources = new LongSparseArray<>(); - private FlutterState flutterState; - private long currentNotificationTextureId = -1; - private Map<String, Object> currentNotificationDataSource; - private Activity activity; - private Handler pipHandler; - private Runnable pipRunnable; - - @Override - public void onAttachedToEngine(FlutterPluginBinding binding) { - FlutterLoader loader = new FlutterLoader(); - this.flutterState = - new FlutterState( - binding.getApplicationContext(), - binding.getBinaryMessenger(), - loader::getLookupKeyForAsset, - loader::getLookupKeyForAsset, - binding.getTextureRegistry()); - flutterState.startListening(this); - } - - @Override - public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) { - if (flutterState == null) { - Log.wtf(TAG, "Detached from the engine before registering to it."); - } - disposeAllPlayers(); - BetterPlayerCache.releaseCache(); - flutterState.stopListening(); - flutterState = null; - } - - private void disposeAllPlayers() { - for (int i = 0; i < videoPlayers.size(); i++) { - videoPlayers.valueAt(i).dispose(); - } - videoPlayers.clear(); - dataSources.clear(); - } - - - @Override - public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) { - if (flutterState == null || flutterState.textureRegistry == null) { - result.error("no_activity", "better_player plugin requires a foreground activity", null); - return; - } - - switch (call.method) { - case INIT_METHOD: - disposeAllPlayers(); - break; - case CREATE_METHOD: { - TextureRegistry.SurfaceTextureEntry handle = - flutterState.textureRegistry.createSurfaceTexture(); - - EventChannel eventChannel = - new EventChannel( - flutterState.binaryMessenger, EVENTS_CHANNEL + handle.id()); - CustomDefaultLoadControl customDefaultLoadControl = null; - if (call.hasArgument(MIN_BUFFER_MS) && call.hasArgument(MAX_BUFFER_MS) && - call.hasArgument(BUFFER_FOR_PLAYBACK_MS) && - call.hasArgument(BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS)) { - - customDefaultLoadControl = - new CustomDefaultLoadControl(call.argument(MIN_BUFFER_MS), - call.argument(MAX_BUFFER_MS), - call.argument(BUFFER_FOR_PLAYBACK_MS), - call.argument(BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS) - ); - } - - BetterPlayer player = - new BetterPlayer(flutterState.applicationContext, eventChannel, handle, - customDefaultLoadControl, result); - - videoPlayers.put(handle.id(), player); - break; - } - case PRE_CACHE_METHOD: - preCache(call, result); - break; - case STOP_PRE_CACHE_METHOD: - stopPreCache(call, result); - break; - case CLEAR_CACHE_METHOD: - clearCache(result); - break; - default: { - long textureId = ((Number) call.argument(TEXTURE_ID_PARAMETER)).longValue(); - BetterPlayer player = videoPlayers.get(textureId); - - if (player == null) { - result.error( - "Unknown textureId", - "No video player associated with texture id " + textureId, - null); - return; - } - onMethodCall(call, result, textureId, player); - break; - } - } - } - - private void onMethodCall(MethodCall call, Result result, long textureId, BetterPlayer player) { - switch (call.method) { - case SET_DATA_SOURCE_METHOD: { - setDataSource(call, result, player); - break; - } - case SET_LOOPING_METHOD: - player.setLooping(call.argument(LOOPING_PARAMETER)); - result.success(null); - break; - case SET_VOLUME_METHOD: - player.setVolume(call.argument(VOLUME_PARAMETER)); - result.success(null); - break; - case PLAY_METHOD: - setupNotification(player); - player.play(); - result.success(null); - break; - case PAUSE_METHOD: - player.pause(); - result.success(null); - break; - case SEEK_TO_METHOD: - int location = ((Number) call.argument(LOCATION_PARAMETER)).intValue(); - player.seekTo(location); - result.success(null); - break; - case POSITION_METHOD: - result.success(player.getPosition()); - player.sendBufferingUpdate(false); - break; - case ABSOLUTE_POSITION_METHOD: - result.success(player.getAbsolutePosition()); - break; - case SET_SPEED_METHOD: - player.setSpeed(call.argument(SPEED_PARAMETER)); - result.success(null); - break; - case SET_TRACK_PARAMETERS_METHOD: - player.setTrackParameters( - call.argument(WIDTH_PARAMETER), - call.argument(HEIGHT_PARAMETER), - call.argument(BITRATE_PARAMETER)); - result.success(null); - break; - case ENABLE_PICTURE_IN_PICTURE_METHOD: - enablePictureInPicture(player); - result.success(null); - break; - - case DISABLE_PICTURE_IN_PICTURE_METHOD: - disablePictureInPicture(player); - result.success(null); - break; - - case IS_PICTURE_IN_PICTURE_SUPPORTED_METHOD: - result.success(isPictureInPictureSupported()); - break; - - case SET_AUDIO_TRACK_METHOD: - player.setAudioTrack(call.argument(NAME_PARAMETER), call.argument(INDEX_PARAMETER)); - result.success(null); - break; - case SET_MIX_WITH_OTHERS_METHOD: - player.setMixWithOthers(call.argument(MIX_WITH_OTHERS_PARAMETER)); - break; - case DISPOSE_METHOD: - dispose(player, textureId); - result.success(null); - break; - default: - result.notImplemented(); - break; - } - } - - - private void setDataSource(MethodCall call, Result result, BetterPlayer player) { - Map<String, Object> dataSource = call.argument(DATA_SOURCE_PARAMETER); - - dataSources.put(getTextureId(player), dataSource); - String key = getParameter(dataSource, KEY_PARAMETER, ""); - Map<String, String> headers = getParameter(dataSource, HEADERS_PARAMETER, new HashMap<>()); - Number overriddenDuration = getParameter(dataSource, OVERRIDDEN_DURATION_PARAMETER, 0); - - if (dataSource.get(ASSET_PARAMETER) != null) { - String asset = getParameter(dataSource, ASSET_PARAMETER, ""); - String assetLookupKey; - if (dataSource.get(PACKAGE_PARAMETER) != null) { - String packageParameter = getParameter(dataSource, PACKAGE_PARAMETER, ""); - assetLookupKey = - flutterState.keyForAssetAndPackageName.get(asset, packageParameter); - } else { - assetLookupKey = flutterState.keyForAsset.get(asset); - } - - player.setDataSource( - flutterState.applicationContext, - key, - "asset:///" + assetLookupKey, - null, - result, - headers, - false, - 0L, - 0L, - overriddenDuration.longValue(), - null, - null, null, null - ); - } else { - boolean useCache = getParameter(dataSource, USE_CACHE_PARAMETER, false); - Number maxCacheSizeNumber = getParameter(dataSource, MAX_CACHE_SIZE_PARAMETER, 0); - Number maxCacheFileSizeNumber = getParameter(dataSource, MAX_CACHE_FILE_SIZE_PARAMETER, 0); - long maxCacheSize = maxCacheSizeNumber.longValue(); - long maxCacheFileSize = maxCacheFileSizeNumber.longValue(); - String uri = getParameter(dataSource, URI_PARAMETER, ""); - String cacheKey = getParameter(dataSource, CACHE_KEY_PARAMETER, null); - String formatHint = getParameter(dataSource, FORMAT_HINT_PARAMETER, null); - String licenseUrl = getParameter(dataSource, LICENSE_URL_PARAMETER, null); - String clearKey = getParameter(dataSource, DRM_CLEARKEY_PARAMETER, null); - Map<String, String> drmHeaders = getParameter(dataSource, DRM_HEADERS_PARAMETER, new HashMap<>()); - player.setDataSource( - flutterState.applicationContext, - key, - uri, - formatHint, - result, - headers, - useCache, - maxCacheSize, - maxCacheFileSize, - overriddenDuration.longValue(), - licenseUrl, - drmHeaders, - cacheKey, - clearKey - ); - } - } - - /** - * Start pre cache of video. - * - * @param call - invoked method data - * @param result - result which should be updated - */ - private void preCache(MethodCall call, Result result) { - Map<String, Object> dataSource = call.argument(DATA_SOURCE_PARAMETER); - if (dataSource != null) { - Number maxCacheSizeNumber = getParameter(dataSource, MAX_CACHE_SIZE_PARAMETER, 100 * 1024 * 1024); - Number maxCacheFileSizeNumber = getParameter(dataSource, MAX_CACHE_FILE_SIZE_PARAMETER, 10 * 1024 * 1024); - long maxCacheSize = maxCacheSizeNumber.longValue(); - long maxCacheFileSize = maxCacheFileSizeNumber.longValue(); - Number preCacheSizeNumber = getParameter(dataSource, PRE_CACHE_SIZE_PARAMETER, 3 * 1024 * 1024); - long preCacheSize = preCacheSizeNumber.longValue(); - String uri = getParameter(dataSource, URI_PARAMETER, ""); - String cacheKey = getParameter(dataSource, CACHE_KEY_PARAMETER, null); - Map<String, String> headers = getParameter(dataSource, HEADERS_PARAMETER, new HashMap<>()); - - BetterPlayer.preCache(flutterState.applicationContext, - uri, - preCacheSize, - maxCacheSize, - maxCacheFileSize, - headers, - cacheKey, - result - ); - } - } - - /** - * Stop pre cache video process (if exists). - * - * @param call - invoked method data - * @param result - result which should be updated - */ - private void stopPreCache(MethodCall call, Result result) { - String url = call.argument(URL_PARAMETER); - BetterPlayer.stopPreCache(flutterState.applicationContext, url, result); - } - - private void clearCache(Result result) { - BetterPlayer.clearCache(flutterState.applicationContext, result); - } - - private Long getTextureId(BetterPlayer betterPlayer) { - for (int index = 0; index < videoPlayers.size(); index++) { - if (betterPlayer == videoPlayers.valueAt(index)) { - return videoPlayers.keyAt(index); - } - } - return null; - } - - private void setupNotification(BetterPlayer betterPlayer) { - try { - Long textureId = getTextureId(betterPlayer); - if (textureId != null) { - Map<String, Object> dataSource = dataSources.get(textureId); - //Don't setup notification for the same source. - if (textureId == currentNotificationTextureId - && currentNotificationDataSource != null - && dataSource != null - && currentNotificationDataSource == dataSource) { - return; - } - currentNotificationDataSource = dataSource; - currentNotificationTextureId = textureId; - removeOtherNotificationListeners(); - boolean showNotification = getParameter(dataSource, SHOW_NOTIFICATION_PARAMETER, false); - if (showNotification) { - String title = getParameter(dataSource, TITLE_PARAMETER, ""); - String author = getParameter(dataSource, AUTHOR_PARAMETER, ""); - String imageUrl = getParameter(dataSource, IMAGE_URL_PARAMETER, ""); - String notificationChannelName = getParameter(dataSource, NOTIFICATION_CHANNEL_NAME_PARAMETER, null); - String activityName = getParameter(dataSource, ACTIVITY_NAME_PARAMETER, "MainActivity"); - betterPlayer.setupPlayerNotification(flutterState.applicationContext, - title, author, imageUrl, notificationChannelName, activityName); - } - } - } catch (Exception exception) { - Log.e(TAG, "SetupNotification failed", exception); - } - } - - private void removeOtherNotificationListeners() { - for (int index = 0; index < videoPlayers.size(); index++) { - videoPlayers.valueAt(index).disposeRemoteNotifications(); - } - } - - @SuppressWarnings("unchecked") - private <T> T getParameter(Map<String, Object> parameters, String key, T defaultValue) { - if (parameters.containsKey(key)) { - Object value = parameters.get(key); - if (value != null) { - return (T) value; - } - } - return defaultValue; - } - - @Override - public void onAttachedToActivity(@NonNull ActivityPluginBinding binding) { - activity = binding.getActivity(); - } - - @Override - public void onDetachedFromActivityForConfigChanges() { - } - - @Override - public void onReattachedToActivityForConfigChanges(@NonNull ActivityPluginBinding binding) { - } - - @Override - public void onDetachedFromActivity() { - } - - private boolean isPictureInPictureSupported() { - return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O - && activity != null - && activity.getPackageManager() - .hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE); - } - - private void enablePictureInPicture(BetterPlayer player) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - player.setupMediaSession(flutterState.applicationContext, true); - activity.enterPictureInPictureMode(new PictureInPictureParams.Builder().build()); - startPictureInPictureListenerTimer(player); - player.onPictureInPictureStatusChanged(true); - } - } - - private void disablePictureInPicture(BetterPlayer player) { - stopPipHandler(); - activity.moveTaskToBack(false); - player.onPictureInPictureStatusChanged(false); - player.disposeMediaSession(); - } - - private void startPictureInPictureListenerTimer(BetterPlayer player) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - pipHandler = new Handler(); - pipRunnable = () -> { - if (activity.isInPictureInPictureMode()) { - pipHandler.postDelayed(pipRunnable, 100); - } else { - player.onPictureInPictureStatusChanged(false); - player.disposeMediaSession(); - stopPipHandler(); - } - }; - pipHandler.post(pipRunnable); - } - } - - private void dispose(BetterPlayer player, long textureId) { - player.dispose(); - videoPlayers.remove(textureId); - dataSources.remove(textureId); - stopPipHandler(); - } - - private void stopPipHandler() { - if (pipHandler != null) { - pipHandler.removeCallbacksAndMessages(null); - pipHandler = null; - } - pipRunnable = null; - } - - private interface KeyForAssetFn { - String get(String asset); - } - - private interface KeyForAssetAndPackageName { - String get(String asset, String packageName); - } - - private static final class FlutterState { - private final Context applicationContext; - private final BinaryMessenger binaryMessenger; - private final KeyForAssetFn keyForAsset; - private final KeyForAssetAndPackageName keyForAssetAndPackageName; - private final TextureRegistry textureRegistry; - private final MethodChannel methodChannel; - - FlutterState( - Context applicationContext, - BinaryMessenger messenger, - KeyForAssetFn keyForAsset, - KeyForAssetAndPackageName keyForAssetAndPackageName, - TextureRegistry textureRegistry) { - this.applicationContext = applicationContext; - this.binaryMessenger = messenger; - this.keyForAsset = keyForAsset; - this.keyForAssetAndPackageName = keyForAssetAndPackageName; - this.textureRegistry = textureRegistry; - methodChannel = new MethodChannel(messenger, CHANNEL); - } - - void startListening(BetterPlayerPlugin methodCallHandler) { - methodChannel.setMethodCallHandler(methodCallHandler); - } - - void stopListening() { - methodChannel.setMethodCallHandler(null); - } - } -} diff --git a/android/src/main/java/com/jhomlala/better_player/CacheDataSourceFactory.java b/android/src/main/java/com/jhomlala/better_player/CacheDataSourceFactory.java deleted file mode 100644 index 5451bbc05..000000000 --- a/android/src/main/java/com/jhomlala/better_player/CacheDataSourceFactory.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.jhomlala.better_player; - -import android.content.Context; -import android.util.Log; - -import com.google.android.exoplayer2.upstream.DataSource; -import com.google.android.exoplayer2.upstream.DefaultBandwidthMeter; -import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; -import com.google.android.exoplayer2.upstream.FileDataSource; -import com.google.android.exoplayer2.upstream.cache.CacheDataSink; -import com.google.android.exoplayer2.upstream.cache.CacheDataSource; -import com.google.android.exoplayer2.upstream.cache.SimpleCache; - -import java.io.File; - -class CacheDataSourceFactory implements DataSource.Factory { - private final Context context; - private final DefaultDataSourceFactory defaultDatasourceFactory; - private final long maxFileSize, maxCacheSize; - - - CacheDataSourceFactory( - Context context, - long maxCacheSize, - long maxFileSize, - DataSource.Factory upstreamDataSource) { - super(); - this.context = context; - this.maxCacheSize = maxCacheSize; - this.maxFileSize = maxFileSize; - DefaultBandwidthMeter bandwidthMeter = new DefaultBandwidthMeter.Builder(context).build(); - defaultDatasourceFactory = - new DefaultDataSourceFactory(this.context, bandwidthMeter, upstreamDataSource); - } - - @SuppressWarnings("NullableProblems") - @Override - public CacheDataSource createDataSource() { - SimpleCache betterPlayerCache = BetterPlayerCache.createCache(context, maxCacheSize); - return new CacheDataSource( - betterPlayerCache, - defaultDatasourceFactory.createDataSource(), - new FileDataSource(), - new CacheDataSink(betterPlayerCache, maxFileSize), - CacheDataSource.FLAG_BLOCK_ON_CACHE | CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR, - null); - } -} \ No newline at end of file diff --git a/android/src/main/java/com/jhomlala/better_player/CacheWorker.java b/android/src/main/java/com/jhomlala/better_player/CacheWorker.java deleted file mode 100644 index f193753f8..000000000 --- a/android/src/main/java/com/jhomlala/better_player/CacheWorker.java +++ /dev/null @@ -1,108 +0,0 @@ -package com.jhomlala.better_player; - -import android.content.Context; -import android.net.Uri; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.work.Data; -import androidx.work.Worker; -import androidx.work.WorkerParameters; - -import com.google.android.exoplayer2.upstream.DataSource; -import com.google.android.exoplayer2.upstream.DataSpec; -import com.google.android.exoplayer2.upstream.HttpDataSource; -import com.google.android.exoplayer2.upstream.cache.CacheWriter; - -import java.util.HashMap; -import java.util.Map; -import java.util.Objects; - - -/** - * Cache worker which download part of video and save in cache for future usage. The cache job - * will be executed in work manager. - */ -public class CacheWorker extends Worker { - private static final String TAG = "CacheWorker"; - private Context mContext; - private CacheWriter mCacheWriter; - private int mLastCacheReportIndex = 0; - - public CacheWorker( - @NonNull Context context, - @NonNull WorkerParameters params) { - super(context, params); - this.mContext = context; - } - - @NonNull - @Override - public Result doWork() { - try { - Data data = getInputData(); - String url = data.getString(BetterPlayerPlugin.URL_PARAMETER); - String cacheKey = data.getString(BetterPlayerPlugin.CACHE_KEY_PARAMETER); - long preCacheSize = data.getLong(BetterPlayerPlugin.PRE_CACHE_SIZE_PARAMETER, 0); - long maxCacheSize = data.getLong(BetterPlayerPlugin.MAX_CACHE_SIZE_PARAMETER, 0); - long maxCacheFileSize = data.getLong(BetterPlayerPlugin.MAX_CACHE_FILE_SIZE_PARAMETER, 0); - Map<String, String> headers = new HashMap<>(); - for (String key : data.getKeyValueMap().keySet()) { - if (key.contains(BetterPlayerPlugin.HEADER_PARAMETER)) { - String keySplit = key.split(BetterPlayerPlugin.HEADER_PARAMETER)[0]; - headers.put(keySplit, (String) Objects.requireNonNull(data.getKeyValueMap().get(key))); - } - } - - Uri uri = Uri.parse(url); - if (DataSourceUtils.isHTTP(uri)) { - String userAgent = DataSourceUtils.getUserAgent(headers); - DataSource.Factory dataSourceFactory = DataSourceUtils.getDataSourceFactory(userAgent, headers); - - DataSpec dataSpec = new DataSpec(uri, 0, preCacheSize); - if (cacheKey != null && cacheKey.length() > 0) { - dataSpec = dataSpec.buildUpon().setKey(cacheKey).build(); - } - - CacheDataSourceFactory cacheDataSourceFactory = - new CacheDataSourceFactory(mContext, maxCacheSize, maxCacheFileSize, dataSourceFactory); - - mCacheWriter = new CacheWriter( - cacheDataSourceFactory.createDataSource(), - dataSpec, - null, - (long requestLength, long bytesCached, long newBytesCached) -> { - double completedData = ((bytesCached * 100f) / preCacheSize); - if (completedData >= mLastCacheReportIndex * 10) { - mLastCacheReportIndex += 1; - Log.d(TAG, "Completed pre cache of " + url + ": " + (int) completedData + "%"); - } - } - ); - - mCacheWriter.cache(); - } else { - Log.e(TAG, "Preloading only possible for remote data sources"); - return Result.failure(); - } - } catch (Exception exception) { - Log.e(TAG, exception.toString()); - if (exception instanceof HttpDataSource.HttpDataSourceException) { - return Result.success(); - } else { - return Result.failure(); - } - } - return Result.success(); - } - - @Override - public void onStopped() { - try { - mCacheWriter.cancel(); - super.onStopped(); - } catch (Exception exception) { - Log.e(TAG, exception.toString()); - } - } -} diff --git a/android/src/main/java/com/jhomlala/better_player/CustomDefaultLoadControl.java b/android/src/main/java/com/jhomlala/better_player/CustomDefaultLoadControl.java deleted file mode 100644 index e6bf0b73d..000000000 --- a/android/src/main/java/com/jhomlala/better_player/CustomDefaultLoadControl.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.jhomlala.better_player; - -import com.google.android.exoplayer2.DefaultLoadControl; - -class CustomDefaultLoadControl { - /** - * The default minimum duration of media that the player will attempt to ensure is buffered - * at all times, in milliseconds. - **/ - public final int minBufferMs; - - /** - * The default maximum duration of media that the player will attempt to buffer, in milliseconds. - **/ - public final int maxBufferMs; - - /** - * 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. - **/ - public final int bufferForPlaybackMs; - - /** - * he 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. - **/ - public final int bufferForPlaybackAfterRebufferMs; - - CustomDefaultLoadControl() { - this.minBufferMs = DefaultLoadControl.DEFAULT_MIN_BUFFER_MS; - this.maxBufferMs = DefaultLoadControl.DEFAULT_MAX_BUFFER_MS; - this.bufferForPlaybackMs = DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS; - this.bufferForPlaybackAfterRebufferMs = - DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS; - - } - - CustomDefaultLoadControl( - Integer minBufferMs, - Integer maxBufferMs, - Integer bufferForPlaybackMs, - Integer bufferForPlaybackAfterRebufferMs - ) { - this.minBufferMs = minBufferMs != null ? minBufferMs : DefaultLoadControl.DEFAULT_MIN_BUFFER_MS; - this.maxBufferMs = maxBufferMs != null ? maxBufferMs : DefaultLoadControl.DEFAULT_MAX_BUFFER_MS; - this.bufferForPlaybackMs = bufferForPlaybackMs != null ? bufferForPlaybackMs : - DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS; - this.bufferForPlaybackAfterRebufferMs = bufferForPlaybackAfterRebufferMs != null ? - bufferForPlaybackAfterRebufferMs : - DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS; - } -} diff --git a/android/src/main/java/com/jhomlala/better_player/DataSourceUtils.java b/android/src/main/java/com/jhomlala/better_player/DataSourceUtils.java deleted file mode 100644 index 65f1cb995..000000000 --- a/android/src/main/java/com/jhomlala/better_player/DataSourceUtils.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.jhomlala.better_player; - -import android.net.Uri; - -import com.google.android.exoplayer2.upstream.DataSource; -import com.google.android.exoplayer2.upstream.DefaultHttpDataSource; - -import java.util.Map; - -class DataSourceUtils { - - private static final String USER_AGENT = "User-Agent"; - private static final String USER_AGENT_PROPERTY = "http.agent"; - - static String getUserAgent(Map<String, String> headers){ - String userAgent = System.getProperty(USER_AGENT_PROPERTY); - if (headers != null && headers.containsKey(USER_AGENT)) { - String userAgentHeader = headers.get(USER_AGENT); - if (userAgentHeader != null) { - userAgent = userAgentHeader; - } - } - return userAgent; - } - - static DataSource.Factory getDataSourceFactory(String userAgent, Map<String, String> headers){ - DataSource.Factory dataSourceFactory = new DefaultHttpDataSource.Factory() - .setUserAgent(userAgent) - .setAllowCrossProtocolRedirects(true) - .setConnectTimeoutMs(DefaultHttpDataSource.DEFAULT_CONNECT_TIMEOUT_MILLIS) - .setReadTimeoutMs(DefaultHttpDataSource.DEFAULT_READ_TIMEOUT_MILLIS); - - if (headers != null) { - ((DefaultHttpDataSource.Factory) dataSourceFactory).setDefaultRequestProperties(headers); - } - return dataSourceFactory; - } - - static boolean isHTTP(Uri uri) { - if (uri == null || uri.getScheme() == null) { - return false; - } - String scheme = uri.getScheme(); - return scheme.equals("http") || scheme.equals("https"); - } -} diff --git a/android/src/main/java/com/jhomlala/better_player/ImageWorker.java b/android/src/main/java/com/jhomlala/better_player/ImageWorker.java deleted file mode 100644 index a59284a6a..000000000 --- a/android/src/main/java/com/jhomlala/better_player/ImageWorker.java +++ /dev/null @@ -1,125 +0,0 @@ -package com.jhomlala.better_player; - -import android.content.Context; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import android.net.Uri; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.work.Data; -import androidx.work.Worker; -import androidx.work.WorkerParameters; - -import java.io.FileOutputStream; -import java.io.InputStream; -import java.net.HttpURLConnection; -import java.net.URL; - -public class ImageWorker extends Worker { - private static final String TAG = "ImageWorker"; - private static final String IMAGE_EXTENSION = ".png"; - private static final int DEFAULT_NOTIFICATION_IMAGE_SIZE_PX = 256; - - public ImageWorker( - @NonNull Context context, - @NonNull WorkerParameters params) { - super(context, params); - } - - @NonNull - @Override - public Result doWork() { - try { - String imageUrl = getInputData().getString(BetterPlayerPlugin.URL_PARAMETER); - if (imageUrl == null) { - return Result.failure(); - } - Bitmap bitmap = null; - if (DataSourceUtils.isHTTP(Uri.parse(imageUrl))) { - bitmap = getBitmapFromExternalURL(imageUrl); - } else { - bitmap = getBitmapFromInternalURL(imageUrl); - } - String fileName = imageUrl.hashCode() + IMAGE_EXTENSION; - String filePath = getApplicationContext().getCacheDir().getAbsolutePath() + fileName; - - if (bitmap == null) { - return Result.failure(); - } - FileOutputStream out = new FileOutputStream(filePath); - bitmap.compress(Bitmap.CompressFormat.PNG, 100, out); - Data data = new Data.Builder().putString(BetterPlayerPlugin.FILE_PATH_PARAMETER, filePath).build(); - return Result.success(data); - } catch (Exception e) { - e.printStackTrace(); - return Result.failure(); - } - } - - - private Bitmap getBitmapFromExternalURL(String src) { - InputStream inputStream = null; - try { - URL url = new URL(src); - HttpURLConnection connection = (HttpURLConnection) url.openConnection(); - inputStream = connection.getInputStream(); - - final BitmapFactory.Options options = new BitmapFactory.Options(); - options.inJustDecodeBounds = true; - BitmapFactory.decodeStream(inputStream, null, options); - inputStream.close(); - connection = (HttpURLConnection) url.openConnection(); - inputStream = connection.getInputStream(); - options.inSampleSize = calculateBitmapInSampleSize( - options); - options.inJustDecodeBounds = false; - return BitmapFactory.decodeStream(inputStream, null, options); - - } catch (Exception exception) { - Log.e(TAG, "Failed to get bitmap from external url: " + src); - return null; - } finally { - try { - if (inputStream != null) { - inputStream.close(); - } - } catch (Exception exception) { - Log.e(TAG, "Failed to close bitmap input stream/"); - } - } - } - - private int calculateBitmapInSampleSize( - BitmapFactory.Options options) { - final int height = options.outHeight; - final int width = options.outWidth; - int inSampleSize = 1; - - if (height > ImageWorker.DEFAULT_NOTIFICATION_IMAGE_SIZE_PX - || width > ImageWorker.DEFAULT_NOTIFICATION_IMAGE_SIZE_PX) { - final int halfHeight = height / 2; - final int halfWidth = width / 2; - while ((halfHeight / inSampleSize) >= ImageWorker.DEFAULT_NOTIFICATION_IMAGE_SIZE_PX - && (halfWidth / inSampleSize) >= ImageWorker.DEFAULT_NOTIFICATION_IMAGE_SIZE_PX) { - inSampleSize *= 2; - } - } - return inSampleSize; - } - - private Bitmap getBitmapFromInternalURL(String src) { - try { - final BitmapFactory.Options options = new BitmapFactory.Options(); - options.inJustDecodeBounds = true; - options.inSampleSize = calculateBitmapInSampleSize(options - ); - options.inJustDecodeBounds = false; - return BitmapFactory.decodeFile(src); - } catch (Exception exception) { - Log.e(TAG, "Failed to get bitmap from internal url: " + src); - return null; - } - } - -} diff --git a/android/src/main/java/com/jhomlala/better_player/QueuingEventSink.java b/android/src/main/java/com/jhomlala/better_player/QueuingEventSink.java deleted file mode 100644 index fc7450d60..000000000 --- a/android/src/main/java/com/jhomlala/better_player/QueuingEventSink.java +++ /dev/null @@ -1,85 +0,0 @@ -// Copyright 2019 The Chromium Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -package com.jhomlala.better_player; - -import io.flutter.plugin.common.EventChannel; -import java.util.ArrayList; - -/** - * And implementation of {@link EventChannel.EventSink} which can wrap an underlying sink. - * - * <p>It delivers messages immediately when downstream is available, but it queues messages before - * the delegate event sink is set with setDelegate. - * - * <p>This class is not thread-safe. All calls must be done on the same thread or synchronized - * externally. - */ -final class QueuingEventSink implements EventChannel.EventSink { - private EventChannel.EventSink delegate; - private ArrayList<Object> eventQueue = new ArrayList<>(); - private boolean done = false; - - public void setDelegate(EventChannel.EventSink delegate) { - this.delegate = delegate; - maybeFlush(); - } - - @Override - public void endOfStream() { - enqueue(new EndOfStreamEvent()); - maybeFlush(); - done = true; - } - - @Override - public void error(String code, String message, Object details) { - enqueue(new ErrorEvent(code, message, details)); - maybeFlush(); - } - - @Override - public void success(Object event) { - enqueue(event); - maybeFlush(); - } - - private void enqueue(Object event) { - if (done) { - return; - } - eventQueue.add(event); - } - - private void maybeFlush() { - if (delegate == null) { - return; - } - for (Object event : eventQueue) { - if (event instanceof EndOfStreamEvent) { - delegate.endOfStream(); - } else if (event instanceof ErrorEvent) { - ErrorEvent errorEvent = (ErrorEvent) event; - delegate.error(errorEvent.code, errorEvent.message, errorEvent.details); - } else { - delegate.success(event); - } - } - eventQueue.clear(); - } - - private static class EndOfStreamEvent {} - - private static class ErrorEvent { - String code; - String message; - Object details; - - ErrorEvent(String code, String message, Object details) { - this.code = code; - this.message = message; - this.details = details; - } - } -} diff --git a/android/src/main/kotlin/com/jhomlala/better_player/BetterPlayer.kt b/android/src/main/kotlin/com/jhomlala/better_player/BetterPlayer.kt new file mode 100644 index 000000000..e1f283185 --- /dev/null +++ b/android/src/main/kotlin/com/jhomlala/better_player/BetterPlayer.kt @@ -0,0 +1,892 @@ +package com.jhomlala.better_player + +import android.annotation.SuppressLint +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.os.Build +import android.os.Handler +import android.os.Looper +import com.jhomlala.better_player.DataSourceUtils.getUserAgent +import com.jhomlala.better_player.DataSourceUtils.isHTTP +import com.jhomlala.better_player.DataSourceUtils.getDataSourceFactory +import io.flutter.plugin.common.EventChannel +import io.flutter.view.TextureRegistry.SurfaceTextureEntry +import io.flutter.plugin.common.MethodChannel +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector +import com.google.android.exoplayer2.ui.PlayerNotificationManager +import android.support.v4.media.session.MediaSessionCompat +import com.google.android.exoplayer2.drm.DrmSessionManager +import androidx.work.WorkManager +import androidx.work.WorkInfo +import com.google.android.exoplayer2.drm.HttpMediaDrmCallback +import com.google.android.exoplayer2.upstream.DefaultHttpDataSource +import com.google.android.exoplayer2.drm.DefaultDrmSessionManager +import com.google.android.exoplayer2.drm.FrameworkMediaDrm +import com.google.android.exoplayer2.drm.UnsupportedDrmException +import com.google.android.exoplayer2.drm.DummyExoMediaDrm +import com.google.android.exoplayer2.drm.LocalMediaDrmCallback +import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory +import com.google.android.exoplayer2.source.MediaSource +import com.google.android.exoplayer2.source.ClippingMediaSource +import com.google.android.exoplayer2.ui.PlayerNotificationManager.MediaDescriptionAdapter +import com.google.android.exoplayer2.ui.PlayerNotificationManager.BitmapCallback +import androidx.work.OneTimeWorkRequest +import android.support.v4.media.session.PlaybackStateCompat +import android.support.v4.media.MediaMetadataCompat +import android.util.Log +import android.view.Surface +import androidx.lifecycle.Observer +import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource +import com.google.android.exoplayer2.source.smoothstreaming.DefaultSsChunkSource +import com.google.android.exoplayer2.source.dash.DashMediaSource +import com.google.android.exoplayer2.source.dash.DefaultDashChunkSource +import com.google.android.exoplayer2.source.hls.HlsMediaSource +import com.google.android.exoplayer2.source.ProgressiveMediaSource +import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory +import io.flutter.plugin.common.EventChannel.EventSink +import androidx.media.session.MediaButtonReceiver +import androidx.work.Data +import com.google.android.exoplayer2.* +import com.google.android.exoplayer2.audio.AudioAttributes +import com.google.android.exoplayer2.drm.DrmSessionManagerProvider +import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector.SelectionOverride +import com.google.android.exoplayer2.upstream.DataSource +import com.google.android.exoplayer2.util.Util +import java.io.File +import java.lang.Exception +import java.lang.IllegalStateException +import java.util.* +import kotlin.math.max +import kotlin.math.min + +internal class BetterPlayer( + context: Context, + private val eventChannel: EventChannel, + private val textureEntry: SurfaceTextureEntry, + customDefaultLoadControl: CustomDefaultLoadControl?, + result: MethodChannel.Result +) { + private val exoPlayer: SimpleExoPlayer? + private val eventSink = QueuingEventSink() + private val trackSelector: DefaultTrackSelector = DefaultTrackSelector(context) + private val loadControl: LoadControl + private var isInitialized = false + private var surface: Surface? = null + private var key: String? = null + private var playerNotificationManager: PlayerNotificationManager? = null + private var refreshHandler: Handler? = null + private var refreshRunnable: Runnable? = null + private var exoPlayerEventListener: Player.Listener? = null + private var bitmap: Bitmap? = null + private var mediaSession: MediaSessionCompat? = null + private var drmSessionManager: DrmSessionManager? = null + private val workManager: WorkManager + private val workerObserverMap: HashMap<UUID, Observer<WorkInfo?>> + private val customDefaultLoadControl: CustomDefaultLoadControl = + customDefaultLoadControl ?: CustomDefaultLoadControl() + private var lastSendBufferedPosition = 0L + + init { + val loadBuilder = DefaultLoadControl.Builder() + loadBuilder.setBufferDurationsMs( + this.customDefaultLoadControl.minBufferMs, + this.customDefaultLoadControl.maxBufferMs, + this.customDefaultLoadControl.bufferForPlaybackMs, + this.customDefaultLoadControl.bufferForPlaybackAfterRebufferMs + ) + loadControl = loadBuilder.build() + exoPlayer = SimpleExoPlayer.Builder(context) + .setTrackSelector(trackSelector) + .setLoadControl(loadControl) + .build() + workManager = WorkManager.getInstance(context) + workerObserverMap = HashMap() + setupVideoPlayer(eventChannel, textureEntry, result) + } + + fun setDataSource( + context: Context, + key: String?, + dataSource: String?, + formatHint: String?, + result: MethodChannel.Result, + headers: Map<String, String>?, + useCache: Boolean, + maxCacheSize: Long, + maxCacheFileSize: Long, + overriddenDuration: Long, + licenseUrl: String?, + drmHeaders: Map<String, String>?, + cacheKey: String?, + clearKey: String? + ) { + this.key = key + isInitialized = false + val uri = Uri.parse(dataSource) + var dataSourceFactory: DataSource.Factory? + val userAgent = getUserAgent(headers) + if (licenseUrl != null && licenseUrl.isNotEmpty()) { + val httpMediaDrmCallback = + HttpMediaDrmCallback(licenseUrl, DefaultHttpDataSource.Factory()) + if (drmHeaders != null) { + for ((drmKey, drmValue) in drmHeaders) { + httpMediaDrmCallback.setKeyRequestProperty(drmKey, drmValue) + } + } + if (Util.SDK_INT < 18) { + Log.e(TAG, "Protected content not supported on API levels below 18") + drmSessionManager = null + } else { + val drmSchemeUuid = Util.getDrmUuid("widevine") + if (drmSchemeUuid != null) { + drmSessionManager = DefaultDrmSessionManager.Builder() + .setUuidAndExoMediaDrmProvider( + drmSchemeUuid + ) { uuid: UUID? -> + try { + val mediaDrm = FrameworkMediaDrm.newInstance(uuid!!) + // Force L3. + mediaDrm.setPropertyString("securityLevel", "L3") + return@setUuidAndExoMediaDrmProvider mediaDrm + } catch (e: UnsupportedDrmException) { + return@setUuidAndExoMediaDrmProvider DummyExoMediaDrm() + } + } + .setMultiSession(false) + .build(httpMediaDrmCallback) + } + } + } else if (clearKey != null && clearKey.isNotEmpty()) { + drmSessionManager = if (Util.SDK_INT < 18) { + Log.e(TAG, "Protected content not supported on API levels below 18") + null + } else { + DefaultDrmSessionManager.Builder() + .setUuidAndExoMediaDrmProvider( + C.CLEARKEY_UUID, + FrameworkMediaDrm.DEFAULT_PROVIDER + ).build(LocalMediaDrmCallback(clearKey.toByteArray())) + } + } else { + drmSessionManager = null + } + if (isHTTP(uri)) { + dataSourceFactory = getDataSourceFactory(userAgent, headers) + if (useCache && maxCacheSize > 0 && maxCacheFileSize > 0) { + dataSourceFactory = CacheDataSourceFactory( + context, + maxCacheSize, + maxCacheFileSize, + dataSourceFactory + ) + } + } else { + dataSourceFactory = DefaultDataSourceFactory(context, userAgent) + } + val mediaSource = buildMediaSource(uri, dataSourceFactory, formatHint, cacheKey, context) + if (overriddenDuration != 0L) { + val clippingMediaSource = ClippingMediaSource(mediaSource, 0, overriddenDuration * 1000) + exoPlayer!!.setMediaSource(clippingMediaSource) + } else { + exoPlayer!!.setMediaSource(mediaSource) + } + exoPlayer.prepare() + result.success(null) + } + + fun setupPlayerNotification( + context: Context, title: String, author: String?, + imageUrl: String?, notificationChannelName: String?, + activityName: String + ) { + val mediaDescriptionAdapter: MediaDescriptionAdapter = object : MediaDescriptionAdapter { + override fun getCurrentContentTitle(player: Player): String { + return title + } + + @SuppressLint("UnspecifiedImmutableFlag") + override fun createCurrentContentIntent(player: Player): PendingIntent? { + val packageName = context.applicationContext.packageName + val notificationIntent = Intent() + notificationIntent.setClassName( + packageName, + "$packageName.$activityName" + ) + notificationIntent.flags = (Intent.FLAG_ACTIVITY_CLEAR_TOP + or Intent.FLAG_ACTIVITY_SINGLE_TOP) + return PendingIntent.getActivity( + context, 0, + notificationIntent, + PendingIntent.FLAG_IMMUTABLE + ) + } + + override fun getCurrentContentText(player: Player): String? { + return author + } + + override fun getCurrentLargeIcon( + player: Player, + callback: BitmapCallback + ): Bitmap? { + if (imageUrl == null) { + return null + } + if (bitmap != null) { + return bitmap + } + val imageWorkRequest = OneTimeWorkRequest.Builder(ImageWorker::class.java) + .addTag(imageUrl) + .setInputData( + Data.Builder() + .putString(BetterPlayerPlugin.URL_PARAMETER, imageUrl) + .build() + ) + .build() + workManager.enqueue(imageWorkRequest) + val workInfoObserver = Observer { workInfo: WorkInfo? -> + try { + if (workInfo != null) { + val state = workInfo.state + if (state == WorkInfo.State.SUCCEEDED) { + val outputData = workInfo.outputData + val filePath = + outputData.getString(BetterPlayerPlugin.FILE_PATH_PARAMETER) + //Bitmap here is already processed and it's very small, so it won't + //break anything. + bitmap = BitmapFactory.decodeFile(filePath) + callback.onBitmap(bitmap!!) + } + if (state == WorkInfo.State.SUCCEEDED || state == WorkInfo.State.CANCELLED || state == WorkInfo.State.FAILED) { + val uuid = imageWorkRequest.id + val observer = workerObserverMap.remove(uuid) + if (observer != null) { + workManager.getWorkInfoByIdLiveData(uuid) + .removeObserver(observer) + } + } + } + } catch (exception: Exception) { + Log.e(TAG, "Image select error: $exception") + } + } + val workerUuid = imageWorkRequest.id + workManager.getWorkInfoByIdLiveData(workerUuid) + .observeForever(workInfoObserver) + workerObserverMap[workerUuid] = workInfoObserver + return null + } + } + var playerNotificationChannelName = notificationChannelName + if (notificationChannelName == null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val importance = NotificationManager.IMPORTANCE_LOW + val channel = NotificationChannel( + DEFAULT_NOTIFICATION_CHANNEL, + DEFAULT_NOTIFICATION_CHANNEL, importance + ) + channel.description = DEFAULT_NOTIFICATION_CHANNEL + val notificationManager = context.getSystemService( + NotificationManager::class.java + ) + notificationManager.createNotificationChannel(channel) + playerNotificationChannelName = DEFAULT_NOTIFICATION_CHANNEL + } + } + playerNotificationManager = PlayerNotificationManager.Builder( + context, + NOTIFICATION_ID, + playerNotificationChannelName!!, + mediaDescriptionAdapter + ).build() + playerNotificationManager!!.setPlayer(exoPlayer) + playerNotificationManager!!.setUseNextAction(false) + playerNotificationManager!!.setUsePreviousAction(false) + playerNotificationManager!!.setUseStopAction(false) + val mediaSession = setupMediaSession(context, false) + playerNotificationManager!!.setMediaSessionToken(mediaSession.sessionToken) + playerNotificationManager!!.setControlDispatcher(setupControlDispatcher()) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + refreshHandler = Handler(Looper.getMainLooper()) + refreshRunnable = Runnable { + val playbackState: PlaybackStateCompat = if (exoPlayer?.isPlaying == true) { + PlaybackStateCompat.Builder() + .setActions(PlaybackStateCompat.ACTION_SEEK_TO) + .setState(PlaybackStateCompat.STATE_PLAYING, position, 1.0f) + .build() + } else { + PlaybackStateCompat.Builder() + .setActions(PlaybackStateCompat.ACTION_SEEK_TO) + .setState(PlaybackStateCompat.STATE_PAUSED, position, 1.0f) + .build() + } + mediaSession.setPlaybackState(playbackState) + refreshHandler!!.postDelayed(refreshRunnable!!, 1000) + } + refreshHandler!!.postDelayed(refreshRunnable!!, 0) + } + exoPlayerEventListener = object : Player.Listener { + override fun onPlaybackStateChanged(playbackState: Int) { + mediaSession.setMetadata( + MediaMetadataCompat.Builder() + .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, getDuration()) + .build() + ) + } + } + exoPlayer!!.addListener(exoPlayerEventListener!!) + exoPlayer.seekTo(0) + } + + private fun setupControlDispatcher(): ControlDispatcher { + return object : ControlDispatcher { + override fun dispatchPrepare(player: Player): Boolean { + return false + } + + override fun dispatchSetPlayWhenReady(player: Player, playWhenReady: Boolean): Boolean { + if (player.playWhenReady) { + sendEvent("pause") + } else { + sendEvent("play") + } + return true + } + + override fun dispatchSeekTo( + player: Player, + windowIndex: Int, + positionMs: Long + ): Boolean { + sendSeekToEvent(positionMs) + return true + } + + override fun dispatchPrevious(player: Player): Boolean { + return false + } + + override fun dispatchNext(player: Player): Boolean { + return false + } + + override fun dispatchRewind(player: Player): Boolean { + sendSeekToEvent(player.currentPosition - 5000) + return false + } + + override fun dispatchFastForward(player: Player): Boolean { + sendSeekToEvent(player.currentPosition + 5000) + return true + } + + override fun dispatchSetRepeatMode(player: Player, repeatMode: Int): Boolean { + return false + } + + override fun dispatchSetShuffleModeEnabled( + player: Player, + shuffleModeEnabled: Boolean + ): Boolean { + return false + } + + override fun dispatchStop(player: Player, reset: Boolean): Boolean { + return false + } + + override fun dispatchSetPlaybackParameters( + player: Player, + playbackParameters: PlaybackParameters + ): Boolean { + return false + } + + override fun isRewindEnabled(): Boolean { + return true + } + + override fun isFastForwardEnabled(): Boolean { + return true + } + } + } + + fun disposeRemoteNotifications() { + if (exoPlayerEventListener != null) { + exoPlayer!!.removeListener(exoPlayerEventListener!!) + } + if (refreshHandler != null) { + refreshHandler!!.removeCallbacksAndMessages(null) + refreshHandler = null + refreshRunnable = null + } + if (playerNotificationManager != null) { + playerNotificationManager!!.setPlayer(null) + } + bitmap = null + } + + private fun buildMediaSource( + uri: Uri, + mediaDataSourceFactory: DataSource.Factory, + formatHint: String?, + cacheKey: String?, + context: Context + ): MediaSource { + val type: Int + if (formatHint == null) { + var lastPathSegment = uri.lastPathSegment + if (lastPathSegment == null) { + lastPathSegment = "" + } + type = Util.inferContentType(lastPathSegment) + } else { + type = when (formatHint) { + FORMAT_SS -> C.TYPE_SS + FORMAT_DASH -> C.TYPE_DASH + FORMAT_HLS -> C.TYPE_HLS + FORMAT_OTHER -> C.TYPE_OTHER + else -> -1 + } + } + val mediaItemBuilder = MediaItem.Builder() + mediaItemBuilder.setUri(uri) + if (cacheKey != null && cacheKey.isNotEmpty()) { + mediaItemBuilder.setCustomCacheKey(cacheKey) + } + val mediaItem = mediaItemBuilder.build() + var drmSessionManagerProvider: DrmSessionManagerProvider? = null + if (drmSessionManager != null) { + drmSessionManagerProvider = DrmSessionManagerProvider { drmSessionManager!! } + } + return when (type) { + C.TYPE_SS -> SsMediaSource.Factory( + DefaultSsChunkSource.Factory(mediaDataSourceFactory), + DefaultDataSourceFactory(context, null, mediaDataSourceFactory) + ) + .setDrmSessionManagerProvider(drmSessionManagerProvider) + .createMediaSource(mediaItem) + C.TYPE_DASH -> DashMediaSource.Factory( + DefaultDashChunkSource.Factory(mediaDataSourceFactory), + DefaultDataSourceFactory(context, null, mediaDataSourceFactory) + ) + .setDrmSessionManagerProvider(drmSessionManagerProvider) + .createMediaSource(mediaItem) + C.TYPE_HLS -> HlsMediaSource.Factory(mediaDataSourceFactory) + .setDrmSessionManagerProvider(drmSessionManagerProvider) + .createMediaSource(mediaItem) + C.TYPE_OTHER -> ProgressiveMediaSource.Factory( + mediaDataSourceFactory, + DefaultExtractorsFactory() + ) + .setDrmSessionManagerProvider(drmSessionManagerProvider) + .createMediaSource(mediaItem) + else -> { + throw IllegalStateException("Unsupported type: $type") + } + } + } + + private fun setupVideoPlayer( + eventChannel: EventChannel, textureEntry: SurfaceTextureEntry, result: MethodChannel.Result + ) { + eventChannel.setStreamHandler( + object : EventChannel.StreamHandler { + override fun onListen(o: Any?, sink: EventSink) { + eventSink.setDelegate(sink) + } + + override fun onCancel(o: Any?) { + eventSink.setDelegate(null) + } + }) + surface = Surface(textureEntry.surfaceTexture()) + exoPlayer!!.setVideoSurface(surface) + setAudioAttributes(exoPlayer, true) + exoPlayer.addListener(object : Player.Listener { + override fun onPlaybackStateChanged(playbackState: Int) { + when (playbackState) { + Player.STATE_BUFFERING -> { + sendBufferingUpdate(true) + val event: MutableMap<String, Any> = HashMap() + event["event"] = "bufferingStart" + eventSink.success(event) + } + Player.STATE_READY -> { + if (!isInitialized) { + isInitialized = true + sendInitialized() + } + val event: MutableMap<String, Any> = HashMap() + event["event"] = "bufferingEnd" + eventSink.success(event) + } + Player.STATE_ENDED -> { + val event: MutableMap<String, Any?> = HashMap() + event["event"] = "completed" + event["key"] = key + eventSink.success(event) + } + Player.STATE_IDLE -> { + //no-op + } + } + } + + override fun onPlayerError(error: PlaybackException) { + eventSink.error("VideoError", "Video player had error $error", "") + } + }) + val reply: MutableMap<String, Any> = HashMap() + reply["textureId"] = textureEntry.id() + result.success(reply) + } + + fun sendBufferingUpdate(isFromBufferingStart: Boolean) { + val bufferedPosition = exoPlayer!!.bufferedPosition + if (isFromBufferingStart || bufferedPosition != lastSendBufferedPosition) { + val event: MutableMap<String, Any> = HashMap() + event["event"] = "bufferingUpdate" + val range: List<Number?> = listOf(0, bufferedPosition) + // iOS supports a list of buffered ranges, so here is a list with a single range. + event["values"] = listOf(range) + eventSink.success(event) + lastSendBufferedPosition = bufferedPosition + } + } + + private fun setAudioAttributes(exoPlayer: SimpleExoPlayer?, mixWithOthers: Boolean) { + val audioComponent = exoPlayer!!.audioComponent ?: return + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + audioComponent.setAudioAttributes( + AudioAttributes.Builder().setContentType(C.CONTENT_TYPE_MOVIE).build(), + !mixWithOthers + ) + } else { + audioComponent.setAudioAttributes( + AudioAttributes.Builder().setContentType(C.CONTENT_TYPE_MUSIC).build(), + !mixWithOthers + ) + } + } + + fun play() { + exoPlayer!!.playWhenReady = true + } + + fun pause() { + exoPlayer!!.playWhenReady = false + } + + fun setLooping(value: Boolean) { + exoPlayer!!.repeatMode = if (value) Player.REPEAT_MODE_ALL else Player.REPEAT_MODE_OFF + } + + fun setVolume(value: Double) { + val bracketedValue = max(0.0, min(1.0, value)) + .toFloat() + exoPlayer!!.volume = bracketedValue + } + + fun setSpeed(value: Double) { + val bracketedValue = value.toFloat() + val playbackParameters = PlaybackParameters(bracketedValue) + exoPlayer!!.playbackParameters = playbackParameters + } + + fun setTrackParameters(width: Int, height: Int, bitrate: Int) { + val parametersBuilder = trackSelector.buildUponParameters() + if (width != 0 && height != 0) { + parametersBuilder.setMaxVideoSize(width, height) + } + if (bitrate != 0) { + parametersBuilder.setMaxVideoBitrate(bitrate) + } + if (width == 0 && height == 0 && bitrate == 0) { + parametersBuilder.clearVideoSizeConstraints() + parametersBuilder.setMaxVideoBitrate(Int.MAX_VALUE) + } + trackSelector.setParameters(parametersBuilder) + } + + fun seekTo(location: Int) { + exoPlayer!!.seekTo(location.toLong()) + } + + val position: Long + get() = exoPlayer!!.currentPosition + val absolutePosition: Long + get() { + val timeline = exoPlayer!!.currentTimeline + if (!timeline.isEmpty) { + val windowStartTimeMs = timeline.getWindow(0, Timeline.Window()).windowStartTimeMs + val pos = exoPlayer.currentPosition + return windowStartTimeMs + pos + } + return exoPlayer.currentPosition + } + + private fun sendInitialized() { + if (isInitialized) { + val event: MutableMap<String, Any?> = HashMap() + event["event"] = "initialized" + event["key"] = key + event["duration"] = getDuration() + if (exoPlayer!!.videoFormat != null) { + val videoFormat = exoPlayer.videoFormat + var width = videoFormat!!.width + var height = videoFormat.height + val rotationDegrees = videoFormat.rotationDegrees + // Switch the width/height if video was taken in portrait mode + if (rotationDegrees == 90 || rotationDegrees == 270) { + width = exoPlayer.videoFormat!!.height + height = exoPlayer.videoFormat!!.width + } + event["width"] = width + event["height"] = height + } + eventSink.success(event) + } + } + + private fun getDuration(): Long = exoPlayer!!.duration + + /** + * Create media session which will be used in notifications, pip mode. + * + * @param context - android context + * @param setupControlDispatcher - should add control dispatcher to created MediaSession + * @return - configured MediaSession instance + */ + fun setupMediaSession(context: Context?, setupControlDispatcher: Boolean): MediaSessionCompat { + mediaSession?.release() + val mediaButtonReceiver = ComponentName(context!!, MediaButtonReceiver::class.java) + val mediaButtonIntent = Intent(Intent.ACTION_MEDIA_BUTTON) + val pendingIntent = PendingIntent.getBroadcast( + context!!, + 0, mediaButtonIntent, + PendingIntent.FLAG_IMMUTABLE + ) + val mediaSession = MediaSessionCompat(context!!, TAG, null, pendingIntent) + mediaSession.setCallback(object : MediaSessionCompat.Callback() { + override fun onSeekTo(pos: Long) { + sendSeekToEvent(pos) + super.onSeekTo(pos) + } + }) + mediaSession.isActive = true + val mediaSessionConnector = MediaSessionConnector(mediaSession) + if (setupControlDispatcher) { + mediaSessionConnector.setControlDispatcher(setupControlDispatcher()) + } + mediaSessionConnector.setPlayer(exoPlayer) + this.mediaSession = mediaSession + return mediaSession + } + + fun onPictureInPictureStatusChanged(inPip: Boolean) { + val event: MutableMap<String, Any> = HashMap() + event["event"] = if (inPip) "pipStart" else "pipStop" + eventSink.success(event) + } + + fun disposeMediaSession() { + if (mediaSession != null) { + mediaSession!!.release() + } + mediaSession = null + } + + private fun sendEvent(eventType: String) { + val event: MutableMap<String, Any> = HashMap() + event["event"] = eventType + eventSink.success(event) + } + + fun setAudioTrack(name: String, index: Int) { + try { + val mappedTrackInfo = trackSelector.currentMappedTrackInfo + if (mappedTrackInfo != null) { + for (rendererIndex in 0 until mappedTrackInfo.rendererCount) { + if (mappedTrackInfo.getRendererType(rendererIndex) != C.TRACK_TYPE_AUDIO) { + continue + } + val trackGroupArray = mappedTrackInfo.getTrackGroups(rendererIndex) + var hasElementWithoutLabel = false + var hasStrangeAudioTrack = false + for (groupIndex in 0 until trackGroupArray.length) { + val group = trackGroupArray[groupIndex] + for (groupElementIndex in 0 until group.length) { + val format = group.getFormat(groupElementIndex) + if (format.label == null) { + hasElementWithoutLabel = true + } + if (format.id != null && format.id == "1/15") { + hasStrangeAudioTrack = true + } + } + } + for (groupIndex in 0 until trackGroupArray.length) { + val group = trackGroupArray[groupIndex] + for (groupElementIndex in 0 until group.length) { + val label = group.getFormat(groupElementIndex).label + if (name == label && index == groupIndex) { + setAudioTrack(rendererIndex, groupIndex, groupElementIndex) + return + } + + ///Fallback option + if (!hasStrangeAudioTrack && hasElementWithoutLabel && index == groupIndex) { + setAudioTrack(rendererIndex, groupIndex, groupElementIndex) + return + } + ///Fallback option + if (hasStrangeAudioTrack && name == label) { + setAudioTrack(rendererIndex, groupIndex, groupElementIndex) + return + } + } + } + } + } + } catch (exception: Exception) { + Log.e(TAG, "setAudioTrack failed$exception") + } + } + + private fun setAudioTrack(rendererIndex: Int, groupIndex: Int, groupElementIndex: Int) { + val mappedTrackInfo = trackSelector.currentMappedTrackInfo + if (mappedTrackInfo != null) { + val builder = trackSelector.parameters.buildUpon() + builder.clearSelectionOverrides(rendererIndex) + .setRendererDisabled(rendererIndex, false) + val tracks = intArrayOf(groupElementIndex) + val override = SelectionOverride(groupIndex, *tracks) + builder.setSelectionOverride( + rendererIndex, + mappedTrackInfo.getTrackGroups(rendererIndex), override + ) + trackSelector.setParameters(builder) + } + } + + private fun sendSeekToEvent(positionMs: Long) { + exoPlayer!!.seekTo(positionMs) + val event: MutableMap<String, Any> = HashMap() + event["event"] = "seek" + event["position"] = positionMs + eventSink.success(event) + } + + fun setMixWithOthers(mixWithOthers: Boolean) { + setAudioAttributes(exoPlayer, mixWithOthers) + } + + fun dispose() { + disposeMediaSession() + disposeRemoteNotifications() + if (isInitialized) { + exoPlayer!!.stop() + } + textureEntry.release() + eventChannel.setStreamHandler(null) + if (surface != null) { + surface!!.release() + } + exoPlayer?.release() + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || javaClass != other.javaClass) return false + val that = other as BetterPlayer + if (if (exoPlayer != null) exoPlayer != that.exoPlayer else that.exoPlayer != null) return false + return if (surface != null) surface == that.surface else that.surface == null + } + + override fun hashCode(): Int { + var result = exoPlayer?.hashCode() ?: 0 + result = 31 * result + if (surface != null) surface.hashCode() else 0 + return result + } + + companion object { + private const val TAG = "BetterPlayer" + private const val FORMAT_SS = "ss" + private const val FORMAT_DASH = "dash" + private const val FORMAT_HLS = "hls" + private const val FORMAT_OTHER = "other" + private const val DEFAULT_NOTIFICATION_CHANNEL = "BETTER_PLAYER_NOTIFICATION" + private const val NOTIFICATION_ID = 20772077 + + //Clear cache without accessing BetterPlayerCache. + fun clearCache(context: Context, result: MethodChannel.Result) { + try { + val file = File(context.cacheDir, "betterPlayerCache") + deleteDirectory(file) + result.success(null) + } catch (exception: Exception) { + Log.e(TAG, exception.toString()) + result.error("", "", "") + } + } + + private fun deleteDirectory(file: File) { + if (file.isDirectory) { + val entries = file.listFiles() + if (entries != null) { + for (entry in entries) { + deleteDirectory(entry) + } + } + } + if (!file.delete()) { + Log.e(TAG, "Failed to delete cache dir.") + } + } + + //Start pre cache of video. Invoke work manager job and start caching in background. + fun preCache( + context: Context?, dataSource: String?, preCacheSize: Long, + maxCacheSize: Long, maxCacheFileSize: Long, headers: Map<String, String?>, + cacheKey: String?, result: MethodChannel.Result + ) { + val dataBuilder = Data.Builder() + .putString(BetterPlayerPlugin.URL_PARAMETER, dataSource) + .putLong(BetterPlayerPlugin.PRE_CACHE_SIZE_PARAMETER, preCacheSize) + .putLong(BetterPlayerPlugin.MAX_CACHE_SIZE_PARAMETER, maxCacheSize) + .putLong(BetterPlayerPlugin.MAX_CACHE_FILE_SIZE_PARAMETER, maxCacheFileSize) + if (cacheKey != null) { + dataBuilder.putString(BetterPlayerPlugin.CACHE_KEY_PARAMETER, cacheKey) + } + for (headerKey in headers.keys) { + dataBuilder.putString( + BetterPlayerPlugin.HEADER_PARAMETER + headerKey, + headers[headerKey] + ) + } + val cacheWorkRequest = OneTimeWorkRequest.Builder(CacheWorker::class.java) + .addTag(dataSource!!) + .setInputData(dataBuilder.build()).build() + WorkManager.getInstance(context!!).enqueue(cacheWorkRequest) + result.success(null) + } + + //Stop pre cache of video with given url. If there's no work manager job for given url, then + //it will be ignored. + fun stopPreCache(context: Context?, url: String?, result: MethodChannel.Result) { + WorkManager.getInstance(context!!).cancelAllWorkByTag(url!!) + result.success(null) + } + } + +} \ No newline at end of file diff --git a/android/src/main/kotlin/com/jhomlala/better_player/BetterPlayerCache.kt b/android/src/main/kotlin/com/jhomlala/better_player/BetterPlayerCache.kt new file mode 100644 index 000000000..d951341d9 --- /dev/null +++ b/android/src/main/kotlin/com/jhomlala/better_player/BetterPlayerCache.kt @@ -0,0 +1,40 @@ +package com.jhomlala.better_player + +import android.content.Context +import android.util.Log +import com.google.android.exoplayer2.upstream.cache.SimpleCache +import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor +import com.google.android.exoplayer2.database.ExoDatabaseProvider +import java.io.File +import java.lang.Exception + +object BetterPlayerCache { + @Volatile + private var instance: SimpleCache? = null + fun createCache(context: Context, cacheFileSize: Long): SimpleCache? { + if (instance == null) { + synchronized(BetterPlayerCache::class.java) { + if (instance == null) { + instance = SimpleCache( + File(context.cacheDir, "betterPlayerCache"), + LeastRecentlyUsedCacheEvictor(cacheFileSize), + ExoDatabaseProvider(context) + ) + } + } + } + return instance + } + + @JvmStatic + fun releaseCache() { + try { + if (instance != null) { + instance!!.release() + instance = null + } + } catch (exception: Exception) { + Log.e("BetterPlayerCache", exception.toString()) + } + } +} \ No newline at end of file diff --git a/android/src/main/kotlin/com/jhomlala/better_player/BetterPlayerPlugin.kt b/android/src/main/kotlin/com/jhomlala/better_player/BetterPlayerPlugin.kt new file mode 100644 index 000000000..ced52802a --- /dev/null +++ b/android/src/main/kotlin/com/jhomlala/better_player/BetterPlayerPlugin.kt @@ -0,0 +1,549 @@ +// Copyright 2017 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +package com.jhomlala.better_player + +import android.app.Activity +import android.app.PictureInPictureParams +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.util.LongSparseArray +import com.jhomlala.better_player.BetterPlayerCache.releaseCache +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import io.flutter.embedding.engine.plugins.FlutterPlugin.FlutterPluginBinding +import io.flutter.embedding.engine.loader.FlutterLoader +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.EventChannel +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.view.TextureRegistry +import java.lang.Exception +import java.util.HashMap + +/** + * Android platform implementation of the VideoPlayerPlugin. + */ +class BetterPlayerPlugin : FlutterPlugin, ActivityAware, MethodCallHandler { + private val videoPlayers = LongSparseArray<BetterPlayer>() + private val dataSources = LongSparseArray<Map<String, Any?>>() + private var flutterState: FlutterState? = null + private var currentNotificationTextureId: Long = -1 + private var currentNotificationDataSource: Map<String, Any?>? = null + private var activity: Activity? = null + private var pipHandler: Handler? = null + private var pipRunnable: Runnable? = null + override fun onAttachedToEngine(binding: FlutterPluginBinding) { + val loader = FlutterLoader() + flutterState = FlutterState( + binding.applicationContext, + binding.binaryMessenger, object : KeyForAssetFn { + override fun get(asset: String?): String { + return loader.getLookupKeyForAsset( + asset!! + ) + } + + }, object : KeyForAssetAndPackageName { + override fun get(asset: String?, packageName: String?): String { + return loader.getLookupKeyForAsset( + asset!!, packageName!! + ) + } + }, + binding.textureRegistry + ) + flutterState!!.startListening(this) + } + + + override fun onDetachedFromEngine(binding: FlutterPluginBinding) { + if (flutterState == null) { + Log.wtf(TAG, "Detached from the engine before registering to it.") + } + disposeAllPlayers() + releaseCache() + flutterState!!.stopListening() + flutterState = null + } + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + activity = binding.activity + } + + override fun onDetachedFromActivityForConfigChanges() {} + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {} + + override fun onDetachedFromActivity() {} + + private fun disposeAllPlayers() { + for (i in 0 until videoPlayers.size()) { + videoPlayers.valueAt(i).dispose() + } + videoPlayers.clear() + dataSources.clear() + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + if (flutterState == null || flutterState!!.textureRegistry == null) { + result.error("no_activity", "better_player plugin requires a foreground activity", null) + return + } + when (call.method) { + INIT_METHOD -> disposeAllPlayers() + CREATE_METHOD -> { + val handle = flutterState!!.textureRegistry!!.createSurfaceTexture() + val eventChannel = EventChannel( + flutterState!!.binaryMessenger, EVENTS_CHANNEL + handle.id() + ) + var customDefaultLoadControl: CustomDefaultLoadControl? = null + if (call.hasArgument(MIN_BUFFER_MS) && call.hasArgument(MAX_BUFFER_MS) && + call.hasArgument(BUFFER_FOR_PLAYBACK_MS) && + call.hasArgument(BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS) + ) { + customDefaultLoadControl = CustomDefaultLoadControl( + call.argument(MIN_BUFFER_MS), + call.argument(MAX_BUFFER_MS), + call.argument(BUFFER_FOR_PLAYBACK_MS), + call.argument(BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS) + ) + } + val player = BetterPlayer( + flutterState!!.applicationContext, eventChannel, handle, + customDefaultLoadControl, result + ) + videoPlayers.put(handle.id(), player) + } + PRE_CACHE_METHOD -> preCache(call, result) + STOP_PRE_CACHE_METHOD -> stopPreCache(call, result) + CLEAR_CACHE_METHOD -> clearCache(result) + else -> { + val textureId = (call.argument<Any>(TEXTURE_ID_PARAMETER) as Number?)!!.toLong() + val player = videoPlayers[textureId] + if (player == null) { + result.error( + "Unknown textureId", + "No video player associated with texture id $textureId", + null + ) + return + } + onMethodCall(call, result, textureId, player) + } + } + } + + private fun onMethodCall( + call: MethodCall, + result: MethodChannel.Result, + textureId: Long, + player: BetterPlayer + ) { + when (call.method) { + SET_DATA_SOURCE_METHOD -> { + setDataSource(call, result, player) + } + SET_LOOPING_METHOD -> { + player.setLooping(call.argument(LOOPING_PARAMETER)!!) + result.success(null) + } + SET_VOLUME_METHOD -> { + player.setVolume(call.argument(VOLUME_PARAMETER)!!) + result.success(null) + } + PLAY_METHOD -> { + setupNotification(player) + player.play() + result.success(null) + } + PAUSE_METHOD -> { + player.pause() + result.success(null) + } + SEEK_TO_METHOD -> { + val location = (call.argument<Any>(LOCATION_PARAMETER) as Number?)!!.toInt() + player.seekTo(location) + result.success(null) + } + POSITION_METHOD -> { + result.success(player.position) + player.sendBufferingUpdate(false) + } + ABSOLUTE_POSITION_METHOD -> result.success(player.absolutePosition) + SET_SPEED_METHOD -> { + player.setSpeed(call.argument(SPEED_PARAMETER)!!) + result.success(null) + } + SET_TRACK_PARAMETERS_METHOD -> { + player.setTrackParameters( + call.argument(WIDTH_PARAMETER)!!, + call.argument(HEIGHT_PARAMETER)!!, + call.argument(BITRATE_PARAMETER)!! + ) + result.success(null) + } + ENABLE_PICTURE_IN_PICTURE_METHOD -> { + enablePictureInPicture(player) + result.success(null) + } + DISABLE_PICTURE_IN_PICTURE_METHOD -> { + disablePictureInPicture(player) + result.success(null) + } + IS_PICTURE_IN_PICTURE_SUPPORTED_METHOD -> result.success( + isPictureInPictureSupported() + ) + SET_AUDIO_TRACK_METHOD -> { + val name = call.argument<String?>(NAME_PARAMETER) + val index = call.argument<Int?>(INDEX_PARAMETER) + if (name != null && index != null) { + player.setAudioTrack(name, index) + } + result.success(null) + } + SET_MIX_WITH_OTHERS_METHOD -> { + val mixWitOthers = call.argument<Boolean?>( + MIX_WITH_OTHERS_PARAMETER + ) + if (mixWitOthers != null) { + player.setMixWithOthers(mixWitOthers) + } + } + DISPOSE_METHOD -> { + dispose(player, textureId) + result.success(null) + } + else -> result.notImplemented() + } + } + + private fun setDataSource( + call: MethodCall, + result: MethodChannel.Result, + player: BetterPlayer + ) { + val dataSource = call.argument<Map<String, Any?>>(DATA_SOURCE_PARAMETER)!! + dataSources.put(getTextureId(player)!!, dataSource) + val key = getParameter(dataSource, KEY_PARAMETER, "") + val headers: Map<String, String> = getParameter(dataSource, HEADERS_PARAMETER, HashMap()) + val overriddenDuration: Number = getParameter(dataSource, OVERRIDDEN_DURATION_PARAMETER, 0) + if (dataSource[ASSET_PARAMETER] != null) { + val asset = getParameter(dataSource, ASSET_PARAMETER, "") + val assetLookupKey: String = if (dataSource[PACKAGE_PARAMETER] != null) { + val packageParameter = getParameter( + dataSource, + PACKAGE_PARAMETER, + "" + ) + flutterState!!.keyForAssetAndPackageName[asset, packageParameter] + } else { + flutterState!!.keyForAsset[asset] + } + player.setDataSource( + flutterState!!.applicationContext, + key, + "asset:///$assetLookupKey", + null, + result, + headers, + false, + 0L, + 0L, + overriddenDuration.toLong(), + null, + null, null, null + ) + } else { + val useCache = getParameter(dataSource, USE_CACHE_PARAMETER, false) + val maxCacheSizeNumber: Number = getParameter(dataSource, MAX_CACHE_SIZE_PARAMETER, 0) + val maxCacheFileSizeNumber: Number = + getParameter(dataSource, MAX_CACHE_FILE_SIZE_PARAMETER, 0) + val maxCacheSize = maxCacheSizeNumber.toLong() + val maxCacheFileSize = maxCacheFileSizeNumber.toLong() + val uri = getParameter(dataSource, URI_PARAMETER, "") + val cacheKey = getParameter<String?>(dataSource, CACHE_KEY_PARAMETER, null) + val formatHint = getParameter<String?>(dataSource, FORMAT_HINT_PARAMETER, null) + val licenseUrl = getParameter<String?>(dataSource, LICENSE_URL_PARAMETER, null) + val clearKey = getParameter<String?>(dataSource, DRM_CLEARKEY_PARAMETER, null) + val drmHeaders: Map<String, String> = + getParameter(dataSource, DRM_HEADERS_PARAMETER, HashMap()) + player.setDataSource( + flutterState!!.applicationContext, + key, + uri, + formatHint, + result, + headers, + useCache, + maxCacheSize, + maxCacheFileSize, + overriddenDuration.toLong(), + licenseUrl, + drmHeaders, + cacheKey, + clearKey + ) + } + } + + /** + * Start pre cache of video. + * + * @param call - invoked method data + * @param result - result which should be updated + */ + private fun preCache(call: MethodCall, result: MethodChannel.Result) { + val dataSource = call.argument<Map<String, Any?>>(DATA_SOURCE_PARAMETER) + if (dataSource != null) { + val maxCacheSizeNumber: Number = + getParameter(dataSource, MAX_CACHE_SIZE_PARAMETER, 100 * 1024 * 1024) + val maxCacheFileSizeNumber: Number = + getParameter(dataSource, MAX_CACHE_FILE_SIZE_PARAMETER, 10 * 1024 * 1024) + val maxCacheSize = maxCacheSizeNumber.toLong() + val maxCacheFileSize = maxCacheFileSizeNumber.toLong() + val preCacheSizeNumber: Number = + getParameter(dataSource, PRE_CACHE_SIZE_PARAMETER, 3 * 1024 * 1024) + val preCacheSize = preCacheSizeNumber.toLong() + val uri = getParameter(dataSource, URI_PARAMETER, "") + val cacheKey = getParameter<String?>(dataSource, CACHE_KEY_PARAMETER, null) + val headers: Map<String, String> = + getParameter(dataSource, HEADERS_PARAMETER, HashMap()) + BetterPlayer.preCache( + flutterState!!.applicationContext, + uri, + preCacheSize, + maxCacheSize, + maxCacheFileSize, + headers, + cacheKey, + result + ) + } + } + + /** + * Stop pre cache video process (if exists). + * + * @param call - invoked method data + * @param result - result which should be updated + */ + private fun stopPreCache(call: MethodCall, result: MethodChannel.Result) { + val url = call.argument<String>(URL_PARAMETER) + BetterPlayer.stopPreCache(flutterState!!.applicationContext, url, result) + } + + private fun clearCache(result: MethodChannel.Result) { + BetterPlayer.clearCache(flutterState!!.applicationContext, result) + } + + private fun getTextureId(betterPlayer: BetterPlayer): Long? { + for (index in 0 until videoPlayers.size()) { + if (betterPlayer === videoPlayers.valueAt(index)) { + return videoPlayers.keyAt(index) + } + } + return null + } + + private fun setupNotification(betterPlayer: BetterPlayer) { + try { + val textureId = getTextureId(betterPlayer) + if (textureId != null) { + val dataSource = dataSources[textureId] + //Don't setup notification for the same source. + if (textureId == currentNotificationTextureId && currentNotificationDataSource != null && dataSource != null && currentNotificationDataSource === dataSource) { + return + } + currentNotificationDataSource = dataSource + currentNotificationTextureId = textureId + removeOtherNotificationListeners() + val showNotification = getParameter(dataSource, SHOW_NOTIFICATION_PARAMETER, false) + if (showNotification) { + val title = getParameter(dataSource, TITLE_PARAMETER, "") + val author = getParameter(dataSource, AUTHOR_PARAMETER, "") + val imageUrl = getParameter(dataSource, IMAGE_URL_PARAMETER, "") + val notificationChannelName = + getParameter<String?>(dataSource, NOTIFICATION_CHANNEL_NAME_PARAMETER, null) + val activityName = + getParameter(dataSource, ACTIVITY_NAME_PARAMETER, "MainActivity") + betterPlayer.setupPlayerNotification( + flutterState!!.applicationContext, + title, author, imageUrl, notificationChannelName, activityName + ) + } + } + } catch (exception: Exception) { + Log.e(TAG, "SetupNotification failed", exception) + } + } + + private fun removeOtherNotificationListeners() { + for (index in 0 until videoPlayers.size()) { + videoPlayers.valueAt(index).disposeRemoteNotifications() + } + } + @Suppress("UNCHECKED_CAST") + private fun <T> getParameter(parameters: Map<String, Any?>?, key: String, defaultValue: T): T { + if (parameters!!.containsKey(key)) { + val value = parameters[key] + if (value != null) { + return value as T + } + } + return defaultValue + } + + + private fun isPictureInPictureSupported(): Boolean { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && activity != null && activity!!.packageManager + .hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) + } + + private fun enablePictureInPicture(player: BetterPlayer) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + player.setupMediaSession(flutterState!!.applicationContext, true) + activity!!.enterPictureInPictureMode(PictureInPictureParams.Builder().build()) + startPictureInPictureListenerTimer(player) + player.onPictureInPictureStatusChanged(true) + } + } + + private fun disablePictureInPicture(player: BetterPlayer) { + stopPipHandler() + activity!!.moveTaskToBack(false) + player.onPictureInPictureStatusChanged(false) + player.disposeMediaSession() + } + + private fun startPictureInPictureListenerTimer(player: BetterPlayer) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + pipHandler = Handler(Looper.getMainLooper()) + pipRunnable = Runnable { + if (activity!!.isInPictureInPictureMode) { + pipHandler!!.postDelayed(pipRunnable!!, 100) + } else { + player.onPictureInPictureStatusChanged(false) + player.disposeMediaSession() + stopPipHandler() + } + } + pipHandler!!.post(pipRunnable!!) + } + } + + private fun dispose(player: BetterPlayer, textureId: Long) { + player.dispose() + videoPlayers.remove(textureId) + dataSources.remove(textureId) + stopPipHandler() + } + + private fun stopPipHandler() { + if (pipHandler != null) { + pipHandler!!.removeCallbacksAndMessages(null) + pipHandler = null + } + pipRunnable = null + } + + private interface KeyForAssetFn { + operator fun get(asset: String?): String + } + + private interface KeyForAssetAndPackageName { + operator fun get(asset: String?, packageName: String?): String + } + + private class FlutterState( + val applicationContext: Context, + val binaryMessenger: BinaryMessenger, + val keyForAsset: KeyForAssetFn, + val keyForAssetAndPackageName: KeyForAssetAndPackageName, + val textureRegistry: TextureRegistry? + ) { + private val methodChannel: MethodChannel = MethodChannel(binaryMessenger, CHANNEL) + + fun startListening(methodCallHandler: BetterPlayerPlugin?) { + methodChannel.setMethodCallHandler(methodCallHandler) + } + + fun stopListening() { + methodChannel.setMethodCallHandler(null) + } + + } + + companion object { + private const val TAG = "BetterPlayerPlugin" + private const val CHANNEL = "better_player_channel" + private const val EVENTS_CHANNEL = "better_player_channel/videoEvents" + private const val DATA_SOURCE_PARAMETER = "dataSource" + private const val KEY_PARAMETER = "key" + private const val HEADERS_PARAMETER = "headers" + private const val USE_CACHE_PARAMETER = "useCache" + private const val ASSET_PARAMETER = "asset" + private const val PACKAGE_PARAMETER = "package" + private const val URI_PARAMETER = "uri" + private const val FORMAT_HINT_PARAMETER = "formatHint" + private const val TEXTURE_ID_PARAMETER = "textureId" + private const val LOOPING_PARAMETER = "looping" + private const val VOLUME_PARAMETER = "volume" + private const val LOCATION_PARAMETER = "location" + private const val SPEED_PARAMETER = "speed" + private const val WIDTH_PARAMETER = "width" + private const val HEIGHT_PARAMETER = "height" + private const val BITRATE_PARAMETER = "bitrate" + private const val SHOW_NOTIFICATION_PARAMETER = "showNotification" + private const val TITLE_PARAMETER = "title" + private const val AUTHOR_PARAMETER = "author" + private const val IMAGE_URL_PARAMETER = "imageUrl" + private const val NOTIFICATION_CHANNEL_NAME_PARAMETER = "notificationChannelName" + private const val OVERRIDDEN_DURATION_PARAMETER = "overriddenDuration" + private const val NAME_PARAMETER = "name" + private const val INDEX_PARAMETER = "index" + private const val LICENSE_URL_PARAMETER = "licenseUrl" + private const val DRM_HEADERS_PARAMETER = "drmHeaders" + private const val DRM_CLEARKEY_PARAMETER = "clearKey" + private const val MIX_WITH_OTHERS_PARAMETER = "mixWithOthers" + const val URL_PARAMETER = "url" + const val PRE_CACHE_SIZE_PARAMETER = "preCacheSize" + const val MAX_CACHE_SIZE_PARAMETER = "maxCacheSize" + const val MAX_CACHE_FILE_SIZE_PARAMETER = "maxCacheFileSize" + const val HEADER_PARAMETER = "header_" + const val FILE_PATH_PARAMETER = "filePath" + const val ACTIVITY_NAME_PARAMETER = "activityName" + const val MIN_BUFFER_MS = "minBufferMs" + const val MAX_BUFFER_MS = "maxBufferMs" + const val BUFFER_FOR_PLAYBACK_MS = "bufferForPlaybackMs" + const val BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS = "bufferForPlaybackAfterRebufferMs" + const val CACHE_KEY_PARAMETER = "cacheKey" + private const val INIT_METHOD = "init" + private const val CREATE_METHOD = "create" + private const val SET_DATA_SOURCE_METHOD = "setDataSource" + private const val SET_LOOPING_METHOD = "setLooping" + private const val SET_VOLUME_METHOD = "setVolume" + private const val PLAY_METHOD = "play" + private const val PAUSE_METHOD = "pause" + private const val SEEK_TO_METHOD = "seekTo" + private const val POSITION_METHOD = "position" + private const val ABSOLUTE_POSITION_METHOD = "absolutePosition" + private const val SET_SPEED_METHOD = "setSpeed" + private const val SET_TRACK_PARAMETERS_METHOD = "setTrackParameters" + private const val SET_AUDIO_TRACK_METHOD = "setAudioTrack" + private const val ENABLE_PICTURE_IN_PICTURE_METHOD = "enablePictureInPicture" + private const val DISABLE_PICTURE_IN_PICTURE_METHOD = "disablePictureInPicture" + private const val IS_PICTURE_IN_PICTURE_SUPPORTED_METHOD = "isPictureInPictureSupported" + private const val SET_MIX_WITH_OTHERS_METHOD = "setMixWithOthers" + private const val CLEAR_CACHE_METHOD = "clearCache" + private const val DISPOSE_METHOD = "dispose" + private const val PRE_CACHE_METHOD = "preCache" + private const val STOP_PRE_CACHE_METHOD = "stopPreCache" + } +} \ No newline at end of file diff --git a/android/src/main/kotlin/com/jhomlala/better_player/CacheDataSourceFactory.kt b/android/src/main/kotlin/com/jhomlala/better_player/CacheDataSourceFactory.kt new file mode 100644 index 000000000..a1e76c957 --- /dev/null +++ b/android/src/main/kotlin/com/jhomlala/better_player/CacheDataSourceFactory.kt @@ -0,0 +1,37 @@ +package com.jhomlala.better_player + +import android.content.Context +import com.google.android.exoplayer2.upstream.DataSource +import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory +import com.google.android.exoplayer2.upstream.cache.CacheDataSource +import com.google.android.exoplayer2.upstream.FileDataSource +import com.google.android.exoplayer2.upstream.cache.CacheDataSink +import com.google.android.exoplayer2.upstream.DefaultBandwidthMeter + +internal class CacheDataSourceFactory( + private val context: Context, + private val maxCacheSize: Long, + private val maxFileSize: Long, + upstreamDataSource: DataSource.Factory? +) : DataSource.Factory { + private val defaultDatasourceFactory: DefaultDataSourceFactory + override fun createDataSource(): CacheDataSource { + val betterPlayerCache = BetterPlayerCache.createCache(context, maxCacheSize) + ?: throw IllegalStateException("Cache can't be null.") + + return CacheDataSource( + betterPlayerCache, + defaultDatasourceFactory.createDataSource(), + FileDataSource(), + CacheDataSink(betterPlayerCache, maxFileSize), + CacheDataSource.FLAG_BLOCK_ON_CACHE or CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR, + null + ) + } + + init { + val bandwidthMeter = DefaultBandwidthMeter.Builder(context).build() + defaultDatasourceFactory = + DefaultDataSourceFactory(context, bandwidthMeter, upstreamDataSource!!) + } +} \ No newline at end of file diff --git a/android/src/main/kotlin/com/jhomlala/better_player/CacheWorker.kt b/android/src/main/kotlin/com/jhomlala/better_player/CacheWorker.kt new file mode 100644 index 000000000..e1f376c5a --- /dev/null +++ b/android/src/main/kotlin/com/jhomlala/better_player/CacheWorker.kt @@ -0,0 +1,99 @@ +package com.jhomlala.better_player + +import android.content.Context +import android.net.Uri +import android.util.Log +import com.jhomlala.better_player.DataSourceUtils.isHTTP +import com.jhomlala.better_player.DataSourceUtils.getUserAgent +import com.jhomlala.better_player.DataSourceUtils.getDataSourceFactory +import androidx.work.WorkerParameters +import com.google.android.exoplayer2.upstream.cache.CacheWriter +import androidx.work.Worker +import com.google.android.exoplayer2.upstream.DataSpec +import com.google.android.exoplayer2.upstream.HttpDataSource.HttpDataSourceException +import java.lang.Exception +import java.util.* + +/** + * Cache worker which download part of video and save in cache for future usage. The cache job + * will be executed in work manager. + */ +class CacheWorker( + private val mContext: Context, + params: WorkerParameters +) : Worker(mContext, params) { + private var mCacheWriter: CacheWriter? = null + private var mLastCacheReportIndex = 0 + override fun doWork(): Result { + try { + val data = inputData + val url = data.getString(BetterPlayerPlugin.URL_PARAMETER) + val cacheKey = data.getString(BetterPlayerPlugin.CACHE_KEY_PARAMETER) + val preCacheSize = data.getLong(BetterPlayerPlugin.PRE_CACHE_SIZE_PARAMETER, 0) + val maxCacheSize = data.getLong(BetterPlayerPlugin.MAX_CACHE_SIZE_PARAMETER, 0) + val maxCacheFileSize = data.getLong(BetterPlayerPlugin.MAX_CACHE_FILE_SIZE_PARAMETER, 0) + val headers: MutableMap<String, String> = HashMap() + for (key in data.keyValueMap.keys) { + if (key.contains(BetterPlayerPlugin.HEADER_PARAMETER)) { + val keySplit = + key.split(BetterPlayerPlugin.HEADER_PARAMETER.toRegex()).toTypedArray()[0] + headers[keySplit] = Objects.requireNonNull(data.keyValueMap[key]) as String + } + } + val uri = Uri.parse(url) + if (isHTTP(uri)) { + val userAgent = getUserAgent(headers) + val dataSourceFactory = getDataSourceFactory(userAgent, headers) + var dataSpec = DataSpec(uri, 0, preCacheSize) + if (cacheKey != null && cacheKey.isNotEmpty()) { + dataSpec = dataSpec.buildUpon().setKey(cacheKey).build() + } + val cacheDataSourceFactory = CacheDataSourceFactory( + mContext, + maxCacheSize, + maxCacheFileSize, + dataSourceFactory + ) + mCacheWriter = CacheWriter( + cacheDataSourceFactory.createDataSource(), + dataSpec, + null + ) { _: Long, bytesCached: Long, _: Long -> + val completedData = (bytesCached * 100f / preCacheSize).toDouble() + if (completedData >= mLastCacheReportIndex * 10) { + mLastCacheReportIndex += 1 + Log.d( + TAG, + "Completed pre cache of " + url + ": " + completedData.toInt() + "%" + ) + } + } + mCacheWriter!!.cache() + } else { + Log.e(TAG, "Preloading only possible for remote data sources") + return Result.failure() + } + } catch (exception: Exception) { + Log.e(TAG, exception.toString()) + return if (exception is HttpDataSourceException) { + Result.success() + } else { + Result.failure() + } + } + return Result.success() + } + + override fun onStopped() { + try { + mCacheWriter!!.cancel() + super.onStopped() + } catch (exception: Exception) { + Log.e(TAG, exception.toString()) + } + } + + companion object { + private const val TAG = "CacheWorker" + } +} \ No newline at end of file diff --git a/android/src/main/kotlin/com/jhomlala/better_player/CustomDefaultLoadControl.kt b/android/src/main/kotlin/com/jhomlala/better_player/CustomDefaultLoadControl.kt new file mode 100644 index 000000000..07a1b0f79 --- /dev/null +++ b/android/src/main/kotlin/com/jhomlala/better_player/CustomDefaultLoadControl.kt @@ -0,0 +1,55 @@ +package com.jhomlala.better_player + +import com.google.android.exoplayer2.DefaultLoadControl + +internal class CustomDefaultLoadControl { + /** + * The default minimum duration of media that the player will attempt to ensure is buffered + * at all times, in milliseconds. + */ + @JvmField + val minBufferMs: Int + + /** + * The default maximum duration of media that the player will attempt to buffer, in milliseconds. + */ + @JvmField + val maxBufferMs: Int + + /** + * 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. + */ + @JvmField + val bufferForPlaybackMs: Int + + /** + * he 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. + */ + @JvmField + val bufferForPlaybackAfterRebufferMs: Int + + constructor() { + minBufferMs = DefaultLoadControl.DEFAULT_MIN_BUFFER_MS + maxBufferMs = DefaultLoadControl.DEFAULT_MAX_BUFFER_MS + bufferForPlaybackMs = DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS + bufferForPlaybackAfterRebufferMs = + DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS + } + + constructor( + minBufferMs: Int?, + maxBufferMs: Int?, + bufferForPlaybackMs: Int?, + bufferForPlaybackAfterRebufferMs: Int? + ) { + this.minBufferMs = minBufferMs ?: DefaultLoadControl.DEFAULT_MIN_BUFFER_MS + this.maxBufferMs = maxBufferMs ?: DefaultLoadControl.DEFAULT_MAX_BUFFER_MS + this.bufferForPlaybackMs = + bufferForPlaybackMs ?: DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS + this.bufferForPlaybackAfterRebufferMs = bufferForPlaybackAfterRebufferMs + ?: DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS + } +} \ No newline at end of file diff --git a/android/src/main/kotlin/com/jhomlala/better_player/DataSourceUtils.kt b/android/src/main/kotlin/com/jhomlala/better_player/DataSourceUtils.kt new file mode 100644 index 000000000..d19c0e6f1 --- /dev/null +++ b/android/src/main/kotlin/com/jhomlala/better_player/DataSourceUtils.kt @@ -0,0 +1,56 @@ +package com.jhomlala.better_player + +import android.net.Uri +import com.google.android.exoplayer2.upstream.DataSource +import com.jhomlala.better_player.DataSourceUtils +import com.google.android.exoplayer2.upstream.DefaultHttpDataSource + +internal object DataSourceUtils { + private const val USER_AGENT = "User-Agent" + private const val USER_AGENT_PROPERTY = "http.agent" + + @JvmStatic + fun getUserAgent(headers: Map<String, String>?): String { + var userAgent = System.getProperty(USER_AGENT_PROPERTY) + if (headers != null && headers.containsKey(USER_AGENT)) { + val userAgentHeader = headers[USER_AGENT] + if (userAgentHeader != null) { + userAgent = userAgentHeader + } + } + return userAgent + } + + @JvmStatic + fun getDataSourceFactory( + userAgent: String?, + headers: Map<String, String>? + ): DataSource.Factory { + val dataSourceFactory: DataSource.Factory = DefaultHttpDataSource.Factory() + .setUserAgent(userAgent) + .setAllowCrossProtocolRedirects(true) + .setConnectTimeoutMs(DefaultHttpDataSource.DEFAULT_CONNECT_TIMEOUT_MILLIS) + .setReadTimeoutMs(DefaultHttpDataSource.DEFAULT_READ_TIMEOUT_MILLIS) + if (headers != null) { + val notNullHeaders = mutableMapOf<String, String>() + headers.forEach { entry -> + if (entry.key != null && entry.value != null) { + notNullHeaders[entry.key!!] = entry.value!! + } + } + (dataSourceFactory as DefaultHttpDataSource.Factory).setDefaultRequestProperties( + notNullHeaders + ) + } + return dataSourceFactory + } + + @JvmStatic + fun isHTTP(uri: Uri?): Boolean { + if (uri == null || uri.scheme == null) { + return false + } + val scheme = uri.scheme + return scheme == "http" || scheme == "https" + } +} \ No newline at end of file diff --git a/android/src/main/kotlin/com/jhomlala/better_player/ImageWorker.kt b/android/src/main/kotlin/com/jhomlala/better_player/ImageWorker.kt new file mode 100644 index 000000000..1a7bea3c4 --- /dev/null +++ b/android/src/main/kotlin/com/jhomlala/better_player/ImageWorker.kt @@ -0,0 +1,120 @@ +package com.jhomlala.better_player + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.util.Log +import androidx.work.Data +import androidx.work.WorkerParameters +import androidx.work.ListenableWorker +import androidx.work.Worker +import com.jhomlala.better_player.BetterPlayerPlugin +import com.jhomlala.better_player.DataSourceUtils +import com.jhomlala.better_player.ImageWorker +import java.io.FileOutputStream +import java.io.InputStream +import java.lang.Exception +import java.net.HttpURLConnection +import java.net.URL + +class ImageWorker( + context: Context, + params: WorkerParameters +) : Worker(context, params) { + override fun doWork(): Result { + return try { + val imageUrl = inputData.getString(BetterPlayerPlugin.URL_PARAMETER) + ?: return Result.failure() + var bitmap: Bitmap? = null + bitmap = if (DataSourceUtils.isHTTP(Uri.parse(imageUrl))) { + getBitmapFromExternalURL(imageUrl) + } else { + getBitmapFromInternalURL(imageUrl) + } + val fileName = imageUrl.hashCode().toString() + IMAGE_EXTENSION + val filePath = applicationContext.cacheDir.absolutePath + fileName + if (bitmap == null) { + return Result.failure() + } + val out = FileOutputStream(filePath) + bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) + val data = + Data.Builder().putString(BetterPlayerPlugin.FILE_PATH_PARAMETER, filePath).build() + Result.success(data) + } catch (e: Exception) { + e.printStackTrace() + Result.failure() + } + } + + private fun getBitmapFromExternalURL(src: String): Bitmap? { + var inputStream: InputStream? = null + return try { + val url = URL(src) + var connection = url.openConnection() as HttpURLConnection + inputStream = connection.inputStream + val options = BitmapFactory.Options() + options.inJustDecodeBounds = true + BitmapFactory.decodeStream(inputStream, null, options) + inputStream.close() + connection = url.openConnection() as HttpURLConnection + inputStream = connection.inputStream + options.inSampleSize = calculateBitmapInSampleSize( + options + ) + options.inJustDecodeBounds = false + BitmapFactory.decodeStream(inputStream, null, options) + } catch (exception: Exception) { + Log.e(TAG, "Failed to get bitmap from external url: $src") + null + } finally { + try { + inputStream?.close() + } catch (exception: Exception) { + Log.e(TAG, "Failed to close bitmap input stream/") + } + } + } + + private fun calculateBitmapInSampleSize( + options: BitmapFactory.Options + ): Int { + val height = options.outHeight + val width = options.outWidth + var inSampleSize = 1 + if (height > DEFAULT_NOTIFICATION_IMAGE_SIZE_PX + || width > DEFAULT_NOTIFICATION_IMAGE_SIZE_PX + ) { + val halfHeight = height / 2 + val halfWidth = width / 2 + while (halfHeight / inSampleSize >= DEFAULT_NOTIFICATION_IMAGE_SIZE_PX + && halfWidth / inSampleSize >= DEFAULT_NOTIFICATION_IMAGE_SIZE_PX + ) { + inSampleSize *= 2 + } + } + return inSampleSize + } + + private fun getBitmapFromInternalURL(src: String): Bitmap? { + return try { + val options = BitmapFactory.Options() + options.inJustDecodeBounds = true + options.inSampleSize = calculateBitmapInSampleSize( + options + ) + options.inJustDecodeBounds = false + BitmapFactory.decodeFile(src) + } catch (exception: Exception) { + Log.e(TAG, "Failed to get bitmap from internal url: $src") + null + } + } + + companion object { + private const val TAG = "ImageWorker" + private const val IMAGE_EXTENSION = ".png" + private const val DEFAULT_NOTIFICATION_IMAGE_SIZE_PX = 256 + } +} \ No newline at end of file diff --git a/android/src/main/kotlin/com/jhomlala/better_player/QueuingEventSink.kt b/android/src/main/kotlin/com/jhomlala/better_player/QueuingEventSink.kt new file mode 100644 index 000000000..8dd4479c8 --- /dev/null +++ b/android/src/main/kotlin/com/jhomlala/better_player/QueuingEventSink.kt @@ -0,0 +1,74 @@ +// Copyright 2019 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +package com.jhomlala.better_player + +import io.flutter.plugin.common.EventChannel.EventSink +import java.util.ArrayList + +/** + * And implementation of [EventSink] which can wrap an underlying sink. + * It delivers messages immediately when downstream is available, but it queues messages before + * the delegate event sink is set with setDelegate. + * This class is not thread-safe. All calls must be done on the same thread or synchronized + * externally. + */ +internal class QueuingEventSink : EventSink { + private var delegate: EventSink? = null + private val eventQueue = ArrayList<Any>() + private var done = false + fun setDelegate(delegate: EventSink?) { + this.delegate = delegate + maybeFlush() + } + + override fun endOfStream() { + enqueue(EndOfStreamEvent()) + maybeFlush() + done = true + } + + override fun error(code: String, message: String, details: Any) { + enqueue(ErrorEvent(code, message, details)) + maybeFlush() + } + + override fun success(event: Any) { + enqueue(event) + maybeFlush() + } + + private fun enqueue(event: Any) { + if (done) { + return + } + eventQueue.add(event) + } + + private fun maybeFlush() { + if (delegate == null) { + return + } + for (event in eventQueue) { + when (event) { + is EndOfStreamEvent -> { + delegate!!.endOfStream() + } + is ErrorEvent -> { + delegate!!.error(event.code, event.message, event.details) + } + else -> { + delegate!!.success(event) + } + } + } + eventQueue.clear() + } + + private class EndOfStreamEvent + private class ErrorEvent( + var code: String, + var message: String, + var details: Any + ) +} \ No newline at end of file diff --git a/docs/events.md b/docs/events.md index de80c8ca7..48ae92ff9 100644 --- a/docs/events.md +++ b/docs/events.md @@ -12,7 +12,8 @@ You can listen to video player events like: finished, exception, controlsVisible, - controlsHidden, + controlsHiddenStart, + controlsHiddenEnd, setSpeed, changedSubtitles, changedTrack, diff --git a/doc/install.md b/docs/install.md similarity index 97% rename from doc/install.md rename to docs/install.md index 7cfc40564..a825565e3 100644 --- a/doc/install.md +++ b/docs/install.md @@ -4,7 +4,7 @@ ```yaml dependencies: - better_player: ^0.0.76 + better_player: ^0.0.78 ``` 2. Install it diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index b04f5e26b..d243520f8 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -26,7 +26,7 @@ apply plugin: 'kotlin-android' apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" android { - compileSdkVersion 29 + compileSdkVersion 31 sourceSets { main.java.srcDirs += 'src/main/kotlin' @@ -37,19 +37,17 @@ android { } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "com.jhomlala.better_player_example" minSdkVersion 16 - targetSdkVersion 28 + targetSdkVersion 31 versionCode flutterVersionCode.toInteger() versionName flutterVersionName + multiDexEnabled true testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildTypes { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. signingConfig signingConfigs.debug } } @@ -60,8 +58,6 @@ flutter { } dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" - testImplementation 'junit:junit:4.12' - androidTestImplementation 'androidx.test:runner:1.1.1' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1' + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion" + implementation "androidx.multidex:multidex:$multidexVersion" } diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index 3074b8e02..9bcf886a4 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -1,13 +1,9 @@ <manifest xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" package="com.jhomlala.better_player_example"> - <!-- io.flutter.app.FlutterApplication is an android.app.Application that - calls FlutterMain.startInitialization(this); in its onCreate method. - In most cases you can leave this as-is, but you if you want to provide - additional functionality it is fine to subclass or reimplement - FlutterApplication and put your custom class here. --> - <application android:usesCleartextTraffic="true"> + <application android:usesCleartextTraffic="true" android:name="io.flutter.app.FlutterApplication" - android:label="better_player_example" + android:label="Better Player Example" android:icon="@mipmap/ic_launcher"> <activity android:name=".MainActivity" @@ -18,14 +14,13 @@ android:resizeableActivity="true" android:supportsPictureInPicture="true" android:theme="@style/LaunchTheme" - android:windowSoftInputMode="adjustResize"> + android:windowSoftInputMode="adjustResize" + android:exported="true"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> - <!-- Don't delete the meta-data below. - This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> <meta-data android:name="flutterEmbedding" android:value="2" /> diff --git a/example/android/app/src/main/kotlin/com/jhomlala/better_player_example/BetterPlayerService.kt b/example/android/app/src/main/kotlin/com/jhomlala/better_player_example/BetterPlayerService.kt index 67c7dff0d..17c43945f 100644 --- a/example/android/app/src/main/kotlin/com/jhomlala/better_player_example/BetterPlayerService.kt +++ b/example/android/app/src/main/kotlin/com/jhomlala/better_player_example/BetterPlayerService.kt @@ -23,22 +23,26 @@ class BetterPlayerService : Service() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { val channelId = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - createNotificationChannel(channelId, "Channel") - } else { - "" - } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + createNotificationChannel(channelId, "Channel") + } else { + "" + } val notificationIntent = Intent(this, MainActivity::class.java) - val pendingIntent = PendingIntent.getActivity(this, 0, notificationIntent, 0) + val pendingIntent = + PendingIntent.getActivity( + this, 0, notificationIntent, + PendingIntent.FLAG_IMMUTABLE + ) val notificationBuilder = NotificationCompat.Builder(this, channelId) - .setContentTitle("Better Player Notification") - .setContentText("Better Player is running") - .setSmallIcon(R.mipmap.ic_launcher) - .setPriority(PRIORITY_MIN) - .setOngoing(true) - .setContentIntent(pendingIntent) + .setContentTitle("Better Player Notification") + .setContentText("Better Player is running") + .setSmallIcon(R.mipmap.ic_launcher) + .setPriority(PRIORITY_MIN) + .setOngoing(true) + .setContentIntent(pendingIntent) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { notificationBuilder.setCategory(Notification.CATEGORY_SERVICE); @@ -49,8 +53,10 @@ class BetterPlayerService : Service() { @RequiresApi(Build.VERSION_CODES.O) private fun createNotificationChannel(channelId: String, channelName: String): String { - val chan = NotificationChannel(channelId, - channelName, NotificationManager.IMPORTANCE_NONE) + val chan = NotificationChannel( + channelId, + channelName, NotificationManager.IMPORTANCE_NONE + ) val service = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager service.createNotificationChannel(chan) return channelId @@ -59,8 +65,9 @@ class BetterPlayerService : Service() { override fun onTaskRemoved(rootIntent: Intent?) { try { val notificationManager = - getSystemService( - Context.NOTIFICATION_SERVICE) as NotificationManager + getSystemService( + Context.NOTIFICATION_SERVICE + ) as NotificationManager notificationManager.cancel(notificationId) } catch (exception: Exception) { diff --git a/example/android/build.gradle b/example/android/build.gradle index 3100ad2d5..ca7d37f31 100644 --- a/example/android/build.gradle +++ b/example/android/build.gradle @@ -1,13 +1,15 @@ buildscript { - ext.kotlin_version = '1.3.50' + ext.kotlinVersion = "1.5.31" + ext.gradleVersion = "4.1.0" + ext.multidexVersion = "2.0.1" repositories { google() jcenter() } dependencies { - classpath 'com.android.tools.build:gradle:3.5.0' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + classpath "com.android.tools.build:gradle:$gradleVersion" + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" } } diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties index 296b146b7..3fcc9eb32 100644 --- a/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/example/android/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,5 @@ 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.7-all.zip + diff --git a/example/lib/pages/custom_controls/change_player_theme_page.dart b/example/lib/pages/custom_controls/change_player_theme_page.dart index 6eec22afe..3bb7b4358 100644 --- a/example/lib/pages/custom_controls/change_player_theme_page.dart +++ b/example/lib/pages/custom_controls/change_player_theme_page.dart @@ -104,9 +104,12 @@ class _ChangePlayerThemePageState extends State<ChangePlayerThemePage> { controlsConfiguration: BetterPlayerControlsConfiguration( playerTheme: _playerTheme, - customControlsBuilder: (controller) => - CustomControlsWidget( + customControlsBuilder: + (controller, onControlsVisibilityChanged) => + CustomControlsWidget( controller: controller, + onControlsVisibilityChanged: + onControlsVisibilityChanged, ), ), ), diff --git a/example/lib/pages/custom_controls/custom_controls_widget.dart b/example/lib/pages/custom_controls/custom_controls_widget.dart index 4644109bf..1eb230ac0 100644 --- a/example/lib/pages/custom_controls/custom_controls_widget.dart +++ b/example/lib/pages/custom_controls/custom_controls_widget.dart @@ -3,8 +3,13 @@ import 'package:flutter/material.dart'; class CustomControlsWidget extends StatefulWidget { final BetterPlayerController? controller; + final Function(bool visbility)? onControlsVisibilityChanged; - const CustomControlsWidget({Key? key, this.controller}) : super(key: key); + const CustomControlsWidget({ + Key? key, + this.controller, + this.onControlsVisibilityChanged, + }) : super(key: key); @override _CustomControlsWidgetState createState() => _CustomControlsWidgetState(); diff --git a/example/lib/pages/video_list/video_list_widget.dart b/example/lib/pages/video_list/video_list_widget.dart index fec693e07..82b682712 100644 --- a/example/lib/pages/video_list/video_list_widget.dart +++ b/example/lib/pages/video_list/video_list_widget.dart @@ -49,7 +49,7 @@ class _VideoListWidgetState extends State<VideoListWidget> { videoListData!.videoUrl, notificationConfiguration: BetterPlayerNotificationConfiguration( - showNotification: true, + showNotification: false, title: videoListData!.videoTitle, author: "Test"), bufferingConfiguration: BetterPlayerBufferingConfiguration( @@ -59,9 +59,7 @@ class _VideoListWidgetState extends State<VideoListWidget> { bufferForPlaybackAfterRebufferMs: 2000), ), configuration: BetterPlayerConfiguration( - autoPlay: false, - aspectRatio: 1, - ), + autoPlay: false, aspectRatio: 1, handleLifecycle: true), //key: Key(videoListData.hashCode.toString()), playFraction: 0.8, betterPlayerListVideoPlayerController: controller, diff --git a/lib/src/configuration/better_player_buffering_configuration.dart b/lib/src/configuration/better_player_buffering_configuration.dart index edc36aef3..1e37212a8 100644 --- a/lib/src/configuration/better_player_buffering_configuration.dart +++ b/lib/src/configuration/better_player_buffering_configuration.dart @@ -3,10 +3,10 @@ class BetterPlayerBufferingConfiguration { ///Constants values are from the offical exoplayer documentation ///https://exoplayer.dev/doc/reference/constant-values.html#com.google.android.exoplayer2.DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS - static const defaultMinBufferMs = 50000; - static const defaultMaxBufferMs = 13107200; - static const defaultBufferForPlaybackMs = 2500; - static const defaultBufferForPlaybackAfterRebufferMs = 5000; + static const defaultMinBufferMs = 25000; + static const defaultMaxBufferMs = 6553600; + static const defaultBufferForPlaybackMs = 3000; + static const defaultBufferForPlaybackAfterRebufferMs = 6000; /// The default minimum duration of media that the player will attempt to /// ensure is buffered at all times, in milliseconds. diff --git a/lib/src/configuration/better_player_controls_configuration.dart b/lib/src/configuration/better_player_controls_configuration.dart index 180a2ab67..8d0bf8026 100644 --- a/lib/src/configuration/better_player_controls_configuration.dart +++ b/lib/src/configuration/better_player_controls_configuration.dart @@ -78,8 +78,8 @@ class BetterPlayerControlsConfiguration { final Duration controlsHideTime; ///Parameter used to build custom controls - final Widget Function(BetterPlayerController controller)? - customControlsBuilder; + final Widget Function(BetterPlayerController controller, + Function(bool) onPlayerVisibilityChanged)? customControlsBuilder; ///Parameter used to change theme of the player final BetterPlayerTheme? playerTheme; diff --git a/lib/src/configuration/better_player_event_type.dart b/lib/src/configuration/better_player_event_type.dart index 1a7391f34..20f3a8f88 100644 --- a/lib/src/configuration/better_player_event_type.dart +++ b/lib/src/configuration/better_player_event_type.dart @@ -11,7 +11,8 @@ enum BetterPlayerEventType { finished, exception, controlsVisible, - controlsHidden, + controlsHiddenStart, + controlsHiddenEnd, setSpeed, changedSubtitles, changedTrack, diff --git a/lib/src/controls/better_player_controls_state.dart b/lib/src/controls/better_player_controls_state.dart index 4991bbc04..dcd490fe8 100644 --- a/lib/src/controls/better_player_controls_state.dart +++ b/lib/src/controls/better_player_controls_state.dart @@ -22,6 +22,8 @@ abstract class BetterPlayerControlsState<T extends StatefulWidget> VideoPlayerValue? get latestValue; + bool controlsNotVisible = true; + void cancelAndRestartTimer(); bool isVideoFinished(VideoPlayerValue? videoPlayerValue) { @@ -517,4 +519,15 @@ abstract class BetterPlayerControlsState<T extends StatefulWidget> Widget buildLTRDirectionality(Widget child) { return Directionality(textDirection: TextDirection.ltr, child: child); } + + ///Called when player controls visibility should be changed. + void changePlayerControlsNotVisible(bool notVisible) { + setState(() { + if (notVisible) { + betterPlayerController?.postEvent( + BetterPlayerEvent(BetterPlayerEventType.controlsHiddenStart)); + } + controlsNotVisible = notVisible; + }); + } } diff --git a/lib/src/controls/better_player_cupertino_controls.dart b/lib/src/controls/better_player_cupertino_controls.dart index 4e67b83d1..f77d911b9 100644 --- a/lib/src/controls/better_player_cupertino_controls.dart +++ b/lib/src/controls/better_player_cupertino_controls.dart @@ -34,7 +34,6 @@ class _BetterPlayerCupertinoControlsState final marginSize = 5.0; VideoPlayerValue? _latestValue; double? _latestVolume; - bool _hideStuff = true; Timer? _hideTimer; Timer? _expandCollapseTimer; Timer? _initTimer; @@ -108,11 +107,9 @@ class _BetterPlayerCupertinoControlsState if (BetterPlayerMultipleGestureDetector.of(context) != null) { BetterPlayerMultipleGestureDetector.of(context)!.onTap?.call(); } - _hideStuff + controlsNotVisible ? cancelAndRestartTimer() - : setState(() { - _hideStuff = true; - }); + : changePlayerControlsNotVisible(true); }, onDoubleTap: () { if (BetterPlayerMultipleGestureDetector.of(context) != null) { @@ -127,7 +124,7 @@ class _BetterPlayerCupertinoControlsState } }, child: AbsorbPointer( - absorbing: _hideStuff, + absorbing: controlsNotVisible, child: isFullScreen ? SafeArea(child: controlsColumn) : controlsColumn), ); @@ -170,7 +167,7 @@ class _BetterPlayerCupertinoControlsState return const SizedBox(); } return AnimatedOpacity( - opacity: _hideStuff ? 0.0 : 1.0, + opacity: controlsNotVisible ? 0.0 : 1.0, duration: _controlsConfiguration.controlsHideTime, onEnd: _onPlayerHide, child: Container( @@ -251,7 +248,7 @@ class _BetterPlayerCupertinoControlsState return GestureDetector( onTap: _onExpandCollapse, child: AnimatedOpacity( - opacity: _hideStuff ? 0.0 : 1.0, + opacity: controlsNotVisible ? 0.0 : 1.0, duration: _controlsConfiguration.controlsHideTime, child: ClipRRect( borderRadius: BorderRadius.circular(10), @@ -281,22 +278,16 @@ class _BetterPlayerCupertinoControlsState child: GestureDetector( onTap: _latestValue != null && _latestValue!.isPlaying ? () { - if (_hideStuff == true) { + if (controlsNotVisible == true) { cancelAndRestartTimer(); } else { _hideTimer?.cancel(); - - setState(() { - _hideStuff = true; - }); + changePlayerControlsNotVisible(true); } } : () { _hideTimer?.cancel(); - - setState(() { - _hideStuff = false; - }); + changePlayerControlsNotVisible(false); }, child: Container( color: Colors.transparent, @@ -318,7 +309,7 @@ class _BetterPlayerCupertinoControlsState onShowMoreClicked(); }, child: AnimatedOpacity( - opacity: _hideStuff ? 0.0 : 1.0, + opacity: controlsNotVisible ? 0.0 : 1.0, duration: _controlsConfiguration.controlsHideTime, child: ClipRRect( borderRadius: BorderRadius.circular(10.0), @@ -363,7 +354,7 @@ class _BetterPlayerCupertinoControlsState } }, child: AnimatedOpacity( - opacity: _hideStuff ? 0.0 : 1.0, + opacity: controlsNotVisible ? 0.0 : 1.0, duration: _controlsConfiguration.controlsHideTime, child: ClipRRect( borderRadius: BorderRadius.circular(10.0), @@ -591,10 +582,8 @@ class _BetterPlayerCupertinoControlsState @override void cancelAndRestartTimer() { _hideTimer?.cancel(); - setState(() { - _hideStuff = false; - _startHideTimer(); - }); + changePlayerControlsNotVisible(false); + _startHideTimer(); } Future<void> _initialize() async { @@ -609,31 +598,25 @@ class _BetterPlayerCupertinoControlsState if (_controlsConfiguration.showControlsOnInitialize) { _initTimer = Timer(const Duration(milliseconds: 200), () { - setState(() { - _hideStuff = false; - }); + changePlayerControlsNotVisible(false); }); } _controlsVisibilityStreamSubscription = _betterPlayerController!.controlsVisibilityStream.listen((state) { - setState(() { - _hideStuff = !state; - }); - if (!_hideStuff) { + changePlayerControlsNotVisible(!state); + + if (!controlsNotVisible) { cancelAndRestartTimer(); } }); } void _onExpandCollapse() { - setState(() { - _hideStuff = true; - - _betterPlayerController!.toggleFullScreen(); - _expandCollapseTimer = Timer(_controlsConfiguration.controlsHideTime, () { - setState(() { - cancelAndRestartTimer(); - }); + changePlayerControlsNotVisible(true); + _betterPlayerController!.toggleFullScreen(); + _expandCollapseTimer = Timer(_controlsConfiguration.controlsHideTime, () { + setState(() { + cancelAndRestartTimer(); }); }); } @@ -651,6 +634,9 @@ class _BetterPlayerCupertinoControlsState onDragEnd: () { _startHideTimer(); }, + onTapDown: () { + cancelAndRestartTimer(); + }, colors: BetterPlayerProgressColors( playedColor: _controlsConfiguration.progressBarPlayedColor, handleColor: _controlsConfiguration.progressBarHandleColor, @@ -668,29 +654,28 @@ class _BetterPlayerCupertinoControlsState if (_latestValue?.position != null && _latestValue?.duration != null) { isFinished = _latestValue!.position >= _latestValue!.duration!; } - setState(() { - if (_controller!.value.isPlaying) { - _hideStuff = false; - _hideTimer?.cancel(); - _betterPlayerController!.pause(); - } else { - cancelAndRestartTimer(); - if (!_controller!.value.initialized) { - if (_betterPlayerController!.betterPlayerDataSource?.liveStream == - true) { - _betterPlayerController!.play(); - _betterPlayerController!.cancelNextVideoTimer(); - } - } else { - if (isFinished) { - _betterPlayerController!.seekTo(const Duration()); - } + if (_controller!.value.isPlaying) { + changePlayerControlsNotVisible(false); + _hideTimer?.cancel(); + _betterPlayerController!.pause(); + } else { + cancelAndRestartTimer(); + + if (!_controller!.value.initialized) { + if (_betterPlayerController!.betterPlayerDataSource?.liveStream == + true) { _betterPlayerController!.play(); _betterPlayerController!.cancelNextVideoTimer(); } + } else { + if (isFinished) { + _betterPlayerController!.seekTo(const Duration()); + } + _betterPlayerController!.play(); + _betterPlayerController!.cancelNextVideoTimer(); } - }); + } } void _startHideTimer() { @@ -698,22 +683,20 @@ class _BetterPlayerCupertinoControlsState return; } _hideTimer = Timer(const Duration(seconds: 3), () { - setState(() { - _hideStuff = true; - }); + changePlayerControlsNotVisible(true); }); } void _updateState() { if (mounted) { - if (!_hideStuff || + if (!controlsNotVisible || isVideoFinished(_controller!.value) || _wasLoading || isLoading(_controller!.value)) { setState(() { _latestValue = _controller!.value; if (isVideoFinished(_latestValue)) { - _hideStuff = false; + changePlayerControlsNotVisible(false); } }); } @@ -721,8 +704,8 @@ class _BetterPlayerCupertinoControlsState } void _onPlayerHide() { - _betterPlayerController!.toggleControlsVisibility(!_hideStuff); - widget.onControlsVisibilityChanged(!_hideStuff); + _betterPlayerController!.toggleControlsVisibility(!controlsNotVisible); + widget.onControlsVisibilityChanged(!controlsNotVisible); } Widget _buildErrorWidget() { @@ -794,7 +777,7 @@ class _BetterPlayerCupertinoControlsState betterPlayerController!.betterPlayerGlobalKey!); }, child: AnimatedOpacity( - opacity: _hideStuff ? 0.0 : 1.0, + opacity: controlsNotVisible ? 0.0 : 1.0, duration: _controlsConfiguration.controlsHideTime, child: ClipRRect( borderRadius: BorderRadius.circular(10), diff --git a/lib/src/controls/better_player_cupertino_progress_bar.dart b/lib/src/controls/better_player_cupertino_progress_bar.dart index d7a3f5f19..000f44e22 100644 --- a/lib/src/controls/better_player_cupertino_progress_bar.dart +++ b/lib/src/controls/better_player_cupertino_progress_bar.dart @@ -14,6 +14,7 @@ class BetterPlayerCupertinoVideoProgressBar extends StatefulWidget { this.onDragEnd, this.onDragStart, this.onDragUpdate, + this.onTapDown, Key? key, }) : colors = colors ?? BetterPlayerProgressColors(), super(key: key); @@ -24,6 +25,7 @@ class BetterPlayerCupertinoVideoProgressBar extends StatefulWidget { final Function()? onDragStart; final Function()? onDragEnd; final Function()? onDragUpdate; + final Function()? onTapDown; @override _VideoProgressBarState createState() { @@ -113,6 +115,9 @@ class _VideoProgressBarState seekToRelativePosition(details.globalPosition); _setupUpdateBlockTimer(); + if (widget.onTapDown != null) { + widget.onTapDown!(); + } }, child: Center( child: Container( diff --git a/lib/src/controls/better_player_material_controls.dart b/lib/src/controls/better_player_material_controls.dart index ce12034d5..ded2121f6 100644 --- a/lib/src/controls/better_player_material_controls.dart +++ b/lib/src/controls/better_player_material_controls.dart @@ -35,7 +35,6 @@ class _BetterPlayerMaterialControlsState extends BetterPlayerControlsState<BetterPlayerMaterialControls> { VideoPlayerValue? _latestValue; double? _latestVolume; - bool _hideStuff = true; Timer? _hideTimer; Timer? _initTimer; Timer? _showAfterExpandCollapseTimer; @@ -77,18 +76,15 @@ class _BetterPlayerMaterialControlsState if (BetterPlayerMultipleGestureDetector.of(context) != null) { BetterPlayerMultipleGestureDetector.of(context)!.onTap?.call(); } - _hideStuff + controlsNotVisible ? cancelAndRestartTimer() - : setState(() { - _hideStuff = true; - }); + : changePlayerControlsNotVisible(true); }, onDoubleTap: () { if (BetterPlayerMultipleGestureDetector.of(context) != null) { BetterPlayerMultipleGestureDetector.of(context)!.onDoubleTap?.call(); } cancelAndRestartTimer(); - //_onPlayPause(); }, onLongPress: () { if (BetterPlayerMultipleGestureDetector.of(context) != null) { @@ -96,10 +92,9 @@ class _BetterPlayerMaterialControlsState } }, child: AbsorbPointer( - absorbing: _hideStuff, + absorbing: controlsNotVisible, child: Stack( fit: StackFit.expand, - //crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (_wasLoading) Center(child: _buildLoadingWidget()) @@ -193,28 +188,29 @@ class _BetterPlayerMaterialControlsState } return Container( - child: (_controlsConfiguration.enableOverflowMenu) - ? AnimatedOpacity( - opacity: _hideStuff ? 0.0 : 1.0, - duration: _controlsConfiguration.controlsHideTime, - onEnd: _onPlayerHide, - child: Container( - //color: _controlsConfiguration.controlBarColor, - height: _controlsConfiguration.controlBarHeight, - width: double.infinity, - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (_controlsConfiguration.enablePip) - _buildPipButtonWrapperWidget(_hideStuff, _onPlayerHide) - else - const SizedBox(), - _buildMoreButton(), - ], - ), + child: (_controlsConfiguration.enableOverflowMenu) + ? AnimatedOpacity( + opacity: controlsNotVisible ? 0.0 : 1.0, + duration: _controlsConfiguration.controlsHideTime, + onEnd: _onPlayerHide, + child: Container( + height: _controlsConfiguration.controlBarHeight, + width: double.infinity, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (_controlsConfiguration.enablePip) + _buildPipButtonWrapperWidget( + controlsNotVisible, _onPlayerHide) + else + const SizedBox(), + _buildMoreButton(), + ], ), - ) - : const SizedBox()); + ), + ) + : const SizedBox(), + ); } Widget _buildPipButton() { @@ -282,12 +278,11 @@ class _BetterPlayerMaterialControlsState return const SizedBox(); } return AnimatedOpacity( - opacity: _hideStuff ? 0.0 : 1.0, + opacity: controlsNotVisible ? 0.0 : 1.0, duration: _controlsConfiguration.controlsHideTime, onEnd: _onPlayerHide, child: Container( height: _controlsConfiguration.controlBarHeight + 20.0, - //color: _controlsConfiguration.controlBarColor, child: Column( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: <Widget>[ @@ -342,7 +337,7 @@ class _BetterPlayerMaterialControlsState return BetterPlayerMaterialClickableWidget( onTap: _onExpandCollapse, child: AnimatedOpacity( - opacity: _hideStuff ? 0.0 : 1.0, + opacity: controlsNotVisible ? 0.0 : 1.0, duration: _controlsConfiguration.controlsHideTime, child: Container( height: _controlsConfiguration.controlBarHeight, @@ -368,7 +363,7 @@ class _BetterPlayerMaterialControlsState return Container( child: Center( child: AnimatedOpacity( - opacity: _hideStuff ? 0.0 : 1.0, + opacity: controlsNotVisible ? 0.0 : 1.0, duration: _controlsConfiguration.controlsHideTime, child: _buildMiddleRow(), ), @@ -378,7 +373,7 @@ class _BetterPlayerMaterialControlsState Widget _buildMiddleRow() { return Container( - color: Colors.black54, //_controlsConfiguration.controlBarColor, + color: _controlsConfiguration.controlBarColor, width: double.infinity, height: double.infinity, child: _betterPlayerController?.isLiveStream() == true @@ -466,18 +461,13 @@ class _BetterPlayerMaterialControlsState if (isFinished) { if (_latestValue != null && _latestValue!.isPlaying) { if (_displayTapped) { - setState(() { - _hideStuff = true; - }); + changePlayerControlsNotVisible(true); } else { cancelAndRestartTimer(); } } else { _onPlayPause(); - - setState(() { - _hideStuff = true; - }); + changePlayerControlsNotVisible(true); } } else { _onPlayPause(); @@ -537,7 +527,7 @@ class _BetterPlayerMaterialControlsState } }, child: AnimatedOpacity( - opacity: _hideStuff ? 0.0 : 1.0, + opacity: controlsNotVisible ? 0.0 : 1.0, duration: _controlsConfiguration.controlsHideTime, child: ClipRect( child: Container( @@ -611,10 +601,8 @@ class _BetterPlayerMaterialControlsState _hideTimer?.cancel(); _startHideTimer(); - setState(() { - _hideStuff = false; - _displayTapped = true; - }); + changePlayerControlsNotVisible(false); + _displayTapped = true; } Future<void> _initialize() async { @@ -629,33 +617,26 @@ class _BetterPlayerMaterialControlsState if (_controlsConfiguration.showControlsOnInitialize) { _initTimer = Timer(const Duration(milliseconds: 200), () { - setState(() { - _hideStuff = false; - }); + changePlayerControlsNotVisible(false); }); } _controlsVisibilityStreamSubscription = _betterPlayerController!.controlsVisibilityStream.listen((state) { - setState(() { - _hideStuff = !state; - }); - if (!_hideStuff) { + changePlayerControlsNotVisible(!state); + if (!controlsNotVisible) { cancelAndRestartTimer(); } }); } void _onExpandCollapse() { - setState(() { - _hideStuff = true; - - _betterPlayerController!.toggleFullScreen(); - _showAfterExpandCollapseTimer = - Timer(_controlsConfiguration.controlsHideTime, () { - setState(() { - cancelAndRestartTimer(); - }); + changePlayerControlsNotVisible(true); + _betterPlayerController!.toggleFullScreen(); + _showAfterExpandCollapseTimer = + Timer(_controlsConfiguration.controlsHideTime, () { + setState(() { + cancelAndRestartTimer(); }); }); } @@ -667,40 +648,36 @@ class _BetterPlayerMaterialControlsState isFinished = _latestValue!.position >= _latestValue!.duration!; } - setState(() { - if (_controller!.value.isPlaying) { - _hideStuff = false; - _hideTimer?.cancel(); - _betterPlayerController!.pause(); - } else { - cancelAndRestartTimer(); + if (_controller!.value.isPlaying) { + changePlayerControlsNotVisible(false); + _hideTimer?.cancel(); + _betterPlayerController!.pause(); + } else { + cancelAndRestartTimer(); - if (!_controller!.value.initialized) { - } else { - if (isFinished) { - _betterPlayerController!.seekTo(const Duration()); - } - _betterPlayerController!.play(); - _betterPlayerController!.cancelNextVideoTimer(); + if (!_controller!.value.initialized) { + } else { + if (isFinished) { + _betterPlayerController!.seekTo(const Duration()); } + _betterPlayerController!.play(); + _betterPlayerController!.cancelNextVideoTimer(); } - }); + } } void _startHideTimer() { if (_betterPlayerController!.controlsAlwaysVisible) { return; } - _hideTimer = Timer(const Duration(seconds: 3), () { - setState(() { - _hideStuff = true; - }); + _hideTimer = Timer(const Duration(milliseconds: 3000), () { + changePlayerControlsNotVisible(true); }); } void _updateState() { if (mounted) { - if (!_hideStuff || + if (!controlsNotVisible || isVideoFinished(_controller!.value) || _wasLoading || isLoading(_controller!.value)) { @@ -708,7 +685,7 @@ class _BetterPlayerMaterialControlsState _latestValue = _controller!.value; if (isVideoFinished(_latestValue) && _betterPlayerController?.isLiveStream() == false) { - _hideStuff = false; + changePlayerControlsNotVisible(false); } }); } @@ -730,6 +707,9 @@ class _BetterPlayerMaterialControlsState onDragEnd: () { _startHideTimer(); }, + onTapDown: () { + cancelAndRestartTimer(); + }, colors: BetterPlayerProgressColors( playedColor: _controlsConfiguration.progressBarPlayedColor, handleColor: _controlsConfiguration.progressBarHandleColor, @@ -742,8 +722,8 @@ class _BetterPlayerMaterialControlsState } void _onPlayerHide() { - _betterPlayerController!.toggleControlsVisibility(!_hideStuff); - widget.onControlsVisibilityChanged(!_hideStuff); + _betterPlayerController!.toggleControlsVisibility(!controlsNotVisible); + widget.onControlsVisibilityChanged(!controlsNotVisible); } Widget? _buildLoadingWidget() { diff --git a/lib/src/controls/better_player_material_progress_bar.dart b/lib/src/controls/better_player_material_progress_bar.dart index fcc066b53..bf261c8a7 100644 --- a/lib/src/controls/better_player_material_progress_bar.dart +++ b/lib/src/controls/better_player_material_progress_bar.dart @@ -14,6 +14,7 @@ class BetterPlayerMaterialVideoProgressBar extends StatefulWidget { this.onDragEnd, this.onDragStart, this.onDragUpdate, + this.onTapDown, Key? key, }) : colors = colors ?? BetterPlayerProgressColors(), super(key: key); @@ -24,6 +25,7 @@ class BetterPlayerMaterialVideoProgressBar extends StatefulWidget { final Function()? onDragStart; final Function()? onDragEnd; final Function()? onDragUpdate; + final Function()? onTapDown; @override _VideoProgressBarState createState() { @@ -116,6 +118,9 @@ class _VideoProgressBarState } seekToRelativePosition(details.globalPosition); _setupUpdateBlockTimer(); + if (widget.onTapDown != null) { + widget.onTapDown!(); + } }, child: Center( child: Container( diff --git a/lib/src/core/better_player_controller.dart b/lib/src/core/better_player_controller.dart index a34dd4b7d..cd259522e 100644 --- a/lib/src/core/better_player_controller.dart +++ b/lib/src/core/better_player_controller.dart @@ -740,7 +740,7 @@ class BetterPlayerController { void toggleControlsVisibility(bool isVisible) { _postEvent(isVisible ? BetterPlayerEvent(BetterPlayerEventType.controlsVisible) - : BetterPlayerEvent(BetterPlayerEventType.controlsHidden)); + : BetterPlayerEvent(BetterPlayerEventType.controlsHiddenEnd)); } ///Send player event. Shouldn't be used manually. diff --git a/lib/src/core/better_player_with_controls.dart b/lib/src/core/better_player_with_controls.dart index 669ad75f9..36ce76b09 100644 --- a/lib/src/core/better_player_with_controls.dart +++ b/lib/src/core/better_player_with_controls.dart @@ -176,8 +176,8 @@ class _BetterPlayerWithControlsState extends State<BetterPlayerWithControls> { if (controlsConfiguration.customControlsBuilder != null && playerTheme == BetterPlayerTheme.custom) { - return controlsConfiguration - .customControlsBuilder!(betterPlayerController); + return controlsConfiguration.customControlsBuilder!( + betterPlayerController, onControlsVisibilityChanged); } else if (playerTheme == BetterPlayerTheme.material) { return _buildMaterialControl(); } else if (playerTheme == BetterPlayerTheme.cupertino) { diff --git a/lib/src/hls/better_player_hls_utils.dart b/lib/src/hls/better_player_hls_utils.dart index 8d5ad8593..0b10ff749 100644 --- a/lib/src/hls/better_player_hls_utils.dart +++ b/lib/src/hls/better_player_hls_utils.dart @@ -113,7 +113,11 @@ class BetterPlayerHlsUtils { // ignore: use_string_buffers realUrl += "${split[index]}/"; } - realUrl += segment.url!; + if (segment.url?.startsWith("http") == true) { + realUrl = segment.url!; + } else { + realUrl += segment.url!; + } hlsSubtitlesUrls.add(realUrl); if (isSegmented) { diff --git a/media/15.png b/media/15.png index 297a556d3..f48265db2 100644 Binary files a/media/15.png and b/media/15.png differ diff --git a/media/16.png b/media/16.png index 0b6a0810f..e99ae98a0 100644 Binary files a/media/16.png and b/media/16.png differ diff --git a/media/2.png b/media/2.png index 3e62a8761..08aa3b5db 100644 Binary files a/media/2.png and b/media/2.png differ diff --git a/pubspec.yaml b/pubspec.yaml index 71bae4471..5a5c327a4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: better_player description: Advanced video player based on video_player and Chewie. It's solves many typical use cases and it's easy to run. -version: 0.0.77 +version: 0.0.78 # Disabled because of warning from analyzer # authors: # - Jakub Homlala <jhomlala@gmail.com> diff --git a/test/better_player_controller_test.dart b/test/better_player_controller_test.dart index 815621618..e6117df33 100644 --- a/test/better_player_controller_test.dart +++ b/test/better_player_controller_test.dart @@ -236,7 +236,7 @@ void main() { controlsVisibleEventCount += 1; } if (event.betterPlayerEventType == - BetterPlayerEventType.controlsHidden) { + BetterPlayerEventType.controlsHiddenEnd) { controlsHiddenEventCount += 1; } });