diff --git a/app/build.gradle b/app/build.gradle index 411458f4e..72ceec002 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,6 +1,6 @@ apply plugin: 'com.android.application' -apply plugin: 'kotlin-android-extensions' apply plugin: 'kotlin-android' +apply plugin: 'kotlin-android-extensions' def version = "0.10.2" android { @@ -29,28 +29,12 @@ android { debuggable true } } - - flavorDimensions "version" - productFlavors { - google { - dimension = "version" - versionName = version + "g" - } - amazon { - dimension = "version" - versionName = version + "a" - } - } } dependencies { def acraVersion = '5.4.0' implementation fileTree(include: ['*.jar'], dir: 'libs') - amazonImplementation 'com.github.jellyfin:exomedia:amazon~2.5.6-SNAPSHOT' - amazonImplementation 'com.github.amzn:exoplayer-amazon-port:amazon-r1.5.6-SNAPSHOT' - googleImplementation 'com.devbrackets.android:exomedia:2.5.6' - googleImplementation 'com.google.android.exoplayer:exoplayer:r1.5.6' // This is split like this because this is a modular gradle project on jitpack // see https://github.com/jitpack/gradle-modular @@ -59,6 +43,7 @@ dependencies { // is better than nothing. implementation 'com.github.jellyfin.jellyfin-apiclient-java:android:master-SNAPSHOT' implementation 'com.github.jellyfin.jellyfin-apiclient-java:library:master-SNAPSHOT' + implementation 'com.amazon.android:exoplayer:2.10.6' implementation 'androidx.recyclerview:recyclerview:1.0.0' implementation 'androidx.leanback:leanback:1.0.0' implementation 'androidx.appcompat:appcompat:1.0.2' @@ -76,6 +61,10 @@ dependencies { testImplementation 'junit:junit:4.12' implementation "androidx.core:core-ktx:1.1.0" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" + implementation 'androidx.constraintlayout:constraintlayout:1.1.3' + implementation 'androidx.tvprovider:tvprovider:1.0.0' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.2' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.2' } repositories { mavenCentral() diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 33f9fceaa..04670a60f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,6 +11,9 @@ + + + @@ -81,13 +84,6 @@ - - diff --git a/app/src/main/java/org/jellyfin/androidtv/TvApp.java b/app/src/main/java/org/jellyfin/androidtv/TvApp.java index ff3a4bb7f..0cd16e81c 100644 --- a/app/src/main/java/org/jellyfin/androidtv/TvApp.java +++ b/app/src/main/java/org/jellyfin/androidtv/TvApp.java @@ -1,26 +1,17 @@ package org.jellyfin.androidtv; -import android.Manifest; import android.app.Activity; -import android.app.AlertDialog; import android.app.Application; import android.content.Context; -import android.content.DialogInterface; import android.content.Intent; import android.content.SharedPreferences; -import android.content.pm.PackageManager; import android.graphics.Bitmap; import android.graphics.Typeface; import android.graphics.drawable.Drawable; import android.graphics.drawable.GradientDrawable; import android.media.AudioManager; -import android.os.Build; import android.preference.PreferenceManager; -import androidx.core.app.ActivityCompat; -import androidx.core.content.ContextCompat; -import androidx.palette.graphics.Palette; - import org.acra.ACRA; import org.acra.annotation.AcraCore; import org.acra.annotation.AcraDialog; @@ -30,19 +21,15 @@ import org.acra.sender.HttpSender; import org.jellyfin.androidtv.base.BaseActivity; import org.jellyfin.androidtv.livetv.TvManager; import org.jellyfin.androidtv.model.DisplayPriorityType; +import org.jellyfin.androidtv.model.LogonCredentials; import org.jellyfin.androidtv.playback.ExternalPlayerActivity; import org.jellyfin.androidtv.playback.MediaManager; import org.jellyfin.androidtv.playback.PlaybackController; import org.jellyfin.androidtv.playback.PlaybackManager; import org.jellyfin.androidtv.playback.PlaybackOverlayActivity; import org.jellyfin.androidtv.search.SearchActivity; -import org.jellyfin.androidtv.model.LogonCredentials; import org.jellyfin.androidtv.util.DeviceUtils; import org.jellyfin.androidtv.util.Utils; - -import java.util.Calendar; -import java.util.HashMap; - import org.jellyfin.apiclient.interaction.ApiClient; import org.jellyfin.apiclient.interaction.EmptyResponse; import org.jellyfin.apiclient.interaction.IConnectionManager; @@ -57,6 +44,12 @@ import org.jellyfin.apiclient.model.entities.DisplayPreferences; import org.jellyfin.apiclient.model.logging.ILogger; import org.jellyfin.apiclient.model.serialization.GsonJsonSerializer; +import java.util.Calendar; +import java.util.HashMap; + +import androidx.core.content.ContextCompat; +import androidx.palette.graphics.Palette; + @AcraCore(buildConfigClass = BuildConfig.class) @AcraHttpSender( uri = "https://collector.tracepot.com/a2eda9d9", @@ -78,7 +71,6 @@ public class TvApp extends Application { public static final int VIDEO_QUEUE_OPTION_ID = 3000; public static final int LIVE_TV_SCHEDULE_OPTION_ID = 4000; public static final int LIVE_TV_SERIES_OPTION_ID = 5000; - public static final int SEARCH_PERMISSION = 0; private static final String TAG = "Jellyfin-AndroidTV"; @@ -116,8 +108,6 @@ public class TvApp extends Application { private long lastMusicPlayback = System.currentTimeMillis(); private long lastUserInteraction = System.currentTimeMillis(); - private boolean voiceSearchAllowed = Build.VERSION.SDK_INT < 23; - private GradientDrawable currentBackgroundGradient; private boolean audioMuted; @@ -237,32 +227,9 @@ public class TvApp extends Application { } public void showSearch(final Activity activity, boolean musicOnly) { - if (!voiceSearchAllowed && - ContextCompat.checkSelfPermission(activity, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) { - //request necessary permission - logger.Info("Requesting search permission..."); - if (ActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.RECORD_AUDIO)) { - //show explanation - logger.Info("Show rationale for permission"); - new AlertDialog.Builder(activity) - .setTitle("Search Permission") - .setMessage("Search requires permission to record audio in order to use the microphone for voice search") - .setPositiveButton("Ok", new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int whichButton) { - ActivityCompat.requestPermissions(activity, new String[] {Manifest.permission.RECORD_AUDIO}, SEARCH_PERMISSION); - } - }).show(); - } else { - ActivityCompat.requestPermissions(activity, new String[] {Manifest.permission.RECORD_AUDIO}, SEARCH_PERMISSION); - } - } else { - showSearchWithPermission(activity, musicOnly); - } - } - - public static void showSearchWithPermission(Context activity, boolean musicOnly) { Intent intent = new Intent(activity, SearchActivity.class); - if (musicOnly) intent.putExtra("MusicOnly", true); + intent.putExtra("MusicOnly", musicOnly); + activity.startActivity(intent); } @@ -309,7 +276,7 @@ public class TvApp extends Application { case Video: case Series: case Recording: - return getPrefs().getBoolean("pref_video_use_external", false); + return getPrefs().getString("pref_video_player", "auto").equals("external"); case TvChannel: case Program: return getPrefs().getBoolean("pref_live_tv_use_external", false); @@ -553,14 +520,6 @@ public class TvApp extends Application { this.httpClient = httpClient; } - public boolean isVoiceSearchAllowed() { - return voiceSearchAllowed; - } - - public void setVoiceSearchAllowed(boolean voiceSearchAllowed) { - this.voiceSearchAllowed = voiceSearchAllowed; - } - public BaseItemDto getLastPlayedItem() { return lastPlayedItem; } diff --git a/app/src/main/java/org/jellyfin/androidtv/base/BaseActivity.java b/app/src/main/java/org/jellyfin/androidtv/base/BaseActivity.java index 97a518088..7b74f9946 100644 --- a/app/src/main/java/org/jellyfin/androidtv/base/BaseActivity.java +++ b/app/src/main/java/org/jellyfin/androidtv/base/BaseActivity.java @@ -1,6 +1,5 @@ package org.jellyfin.androidtv.base; -import android.content.pm.PackageManager; import android.graphics.Rect; import android.os.Bundle; import android.os.Handler; @@ -13,14 +12,13 @@ import android.widget.FrameLayout; import android.widget.ImageView; import android.widget.TextView; -import androidx.annotation.NonNull; -import androidx.core.app.ActivityCompat; -import androidx.fragment.app.FragmentActivity; - import org.jellyfin.androidtv.R; import org.jellyfin.androidtv.TvApp; import org.jellyfin.androidtv.util.Utils; +import androidx.core.app.ActivityCompat; +import androidx.fragment.app.FragmentActivity; + public abstract class BaseActivity extends FragmentActivity implements ActivityCompat.OnRequestPermissionsResultCallback { private TvApp app = TvApp.getApplication(); private long timeoutInterval = 3600000; @@ -191,16 +189,4 @@ public abstract class BaseActivity extends FragmentActivity implements ActivityC TvApp.getApplication().showSearch(this, false); return true; } - - @Override - public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { - // Search functionality requests access to RECORD_AUDIO - if (requestCode == TvApp.SEARCH_PERMISSION) { - // If request is cancelled, the result arrays are empty. - if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - TvApp.getApplication().setVoiceSearchAllowed(true); - } - TvApp.showSearchWithPermission(this, false); - } - } } diff --git a/app/src/main/java/org/jellyfin/androidtv/browsing/HomeFragment.java b/app/src/main/java/org/jellyfin/androidtv/browsing/HomeFragment.java index 9c0e15692..82b0bca45 100644 --- a/app/src/main/java/org/jellyfin/androidtv/browsing/HomeFragment.java +++ b/app/src/main/java/org/jellyfin/androidtv/browsing/HomeFragment.java @@ -10,8 +10,8 @@ import org.jellyfin.androidtv.R; import org.jellyfin.androidtv.TvApp; import org.jellyfin.androidtv.base.CustomMessage; import org.jellyfin.androidtv.base.IMessageListener; +import org.jellyfin.androidtv.channels.ChannelManager; import org.jellyfin.androidtv.constants.HomeSectionType; -import org.jellyfin.androidtv.integration.RecommendationManager; import org.jellyfin.androidtv.itemhandling.ItemRowAdapter; import org.jellyfin.androidtv.livetv.LiveTvGuideActivity; import org.jellyfin.androidtv.model.ChangeTriggerType; @@ -70,6 +70,8 @@ public class HomeFragment extends StdBrowseFragment { private HomeFragmentNowPlayingRow nowPlaying; private HomeFragmentFooterRow footer; + private ChannelManager channelManager; + private AudioEventListener audioEventListener = new AudioEventListener() { @Override public void onQueueStatusChanged(boolean hasQueue) { @@ -90,8 +92,8 @@ public class HomeFragment extends StdBrowseFragment { TvApp.getApplication().getLogger().ErrorException("Unable to save login credentials", e); } - // Init recommendations - RecommendationManager.init(); + // Init leanback home channels; + channelManager = new ChannelManager(); // Get auto bitrate TvApp.getApplication().determineAutoBitrate(); @@ -146,6 +148,9 @@ public class HomeFragment extends StdBrowseFragment { public void onResume() { super.onResume(); + // Update leanback channels + channelManager.update(); + //make sure rows have had a chance to be created new Handler().postDelayed(new Runnable() { @Override diff --git a/app/src/main/java/org/jellyfin/androidtv/channels/ChannelManager.kt b/app/src/main/java/org/jellyfin/androidtv/channels/ChannelManager.kt new file mode 100644 index 000000000..04732c7b1 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/channels/ChannelManager.kt @@ -0,0 +1,124 @@ +package org.jellyfin.androidtv.channels + +import android.content.Intent +import android.net.Uri +import androidx.tvprovider.media.tv.TvContractCompat.WatchNextPrograms +import androidx.tvprovider.media.tv.WatchNextProgram +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.jellyfin.androidtv.TvApp +import org.jellyfin.androidtv.startup.StartupActivity +import org.jellyfin.androidtv.util.apiclient.getNextUpEpisodes +import org.jellyfin.apiclient.model.drawing.ImageFormat +import org.jellyfin.apiclient.model.dto.BaseItemDto +import org.jellyfin.apiclient.model.dto.ImageOptions +import org.jellyfin.apiclient.model.querying.ItemFields +import org.jellyfin.apiclient.model.querying.NextUpQuery + +/** + * Manages channels on the android tv home screen + * + * More info: https://developer.android.com/training/tv/discovery/recommendations-channel + */ +class ChannelManager { + private companion object { + /** + * Amount of ticks found in a millisecond, used for calculation + */ + private const val TICKS_IN_MILLISECOND = 10000 + } + + private val application = TvApp.getApplication() + + /** + * Check if the app can use Leanback features + */ + private val isSupported = application.packageManager.hasSystemFeature("android.software.leanback") + + /** + * Update all channels for the currently authenticated user + */ + fun update() { + // Don't do anything if not supported + if (!isSupported) return + + // Launch in separate coroutine scope + GlobalScope.launch { + updateWatchNext() + } + } + + /** + * Updates the "watch next" row with new and unfinished episodes + * does not include movies, music or other types of media + */ + private suspend fun updateWatchNext() = withContext(Dispatchers.Default) { + // Delete current items + application.contentResolver.delete(WatchNextPrograms.CONTENT_URI, null, null) + + // Get user or return if no user is found (not authenticated) + val user = application.currentUser ?: return@withContext + + // Get new items + val response = application.apiClient.getNextUpEpisodes(NextUpQuery().apply { + userId = user.id + imageTypeLimit = 1 + limit = 10 + fields = arrayOf(ItemFields.DateCreated) + }) + + // Add new items + response?.items?.forEach { item -> + application.contentResolver.insert( + WatchNextPrograms.CONTENT_URI, + getBaseItemAsWatchNextProgram(item).toContentValues() + ) + } + } + + /** + * Convert [BaseItemDto] to [WatchNextProgram] + * + * Assumes the item type is "episode" + */ + private fun getBaseItemAsWatchNextProgram(item: BaseItemDto) = WatchNextProgram.Builder().apply { + setInternalProviderId(item.id) + setType(WatchNextPrograms.TYPE_TV_EPISODE) + setTitle("${item.seriesName} - ${item.name}") + + // Poster image + setPosterArtAspectRatio(WatchNextPrograms.ASPECT_RATIO_16_9) + setPosterArtUri(Uri.parse(application.apiClient.GetImageUrl(item, ImageOptions().apply { + format = ImageFormat.Png + height = 288 + width = 512 + }))) + + // Use date created or fallback to current time if unavailable + setLastEngagementTimeUtcMillis(item.dateCreated?.time ?: System.currentTimeMillis()) + + when { + // User has started playing the episode + item.canResume -> { + setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE) + setLastPlaybackPositionMillis((item.resumePositionTicks / TICKS_IN_MILLISECOND).toInt()) + setDurationMillis((item.runTimeTicks / TICKS_IN_MILLISECOND).toInt()) + } + // First episode of the season + item.indexNumber == 0 -> { + setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_NEW) + } + // Default + else -> { + setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_NEXT) + } + } + + // Set intent to open the episode + setIntent(Intent(application, StartupActivity::class.java).apply { + putExtra("ItemId", item.id) + }) + }.build() +} diff --git a/app/src/main/java/org/jellyfin/androidtv/integration/Recommendation.java b/app/src/main/java/org/jellyfin/androidtv/integration/Recommendation.java deleted file mode 100644 index 8d6cfa3db..000000000 --- a/app/src/main/java/org/jellyfin/androidtv/integration/Recommendation.java +++ /dev/null @@ -1,51 +0,0 @@ -package org.jellyfin.androidtv.integration; - -public class Recommendation { - private RecommendationType type; - private String itemId; - private Integer recId; - private long dateAdded; - - public Recommendation(RecommendationType type, String id) { - itemId = id; - this.type = type; - this.dateAdded = System.currentTimeMillis(); - } - - public String getItemId() { - return itemId; - } - - public void setItemId(String mItemId) { - this.itemId = mItemId; - } - - public Integer getRecId() { - return recId; - } - - public void setRecId(Integer mRecId) { - this.recId = mRecId; - } - - public long getDateAdded() { - return dateAdded; - } - - public RecommendationType getType() { - return type; - } - - public void setType(RecommendationType mType) { - this.type = mType; - } - - @Override - public boolean equals(Object o) { - if (o instanceof Recommendation) { - Recommendation compare = (Recommendation)o; - return compare.getType() == this.getType() && compare.getItemId().equals(this.getItemId()); - } - return false; - } -} diff --git a/app/src/main/java/org/jellyfin/androidtv/integration/RecommendationBuilder.java b/app/src/main/java/org/jellyfin/androidtv/integration/RecommendationBuilder.java deleted file mode 100644 index 61c4ebcb5..000000000 --- a/app/src/main/java/org/jellyfin/androidtv/integration/RecommendationBuilder.java +++ /dev/null @@ -1,127 +0,0 @@ -package org.jellyfin.androidtv.integration; - -import android.app.Notification; -import android.app.PendingIntent; -import android.content.Context; -import android.graphics.Bitmap; -import android.net.Uri; -import android.os.Bundle; -import androidx.core.app.NotificationCompat; -import android.util.Log; - -import org.jellyfin.androidtv.util.Utils; - -import java.io.UnsupportedEncodingException; -import java.net.URLEncoder; - -/** - * Created by Eric on 3/1/2015. - */ -public class RecommendationBuilder { - private static final String TAG = "RecommendationBuilder"; - private static final String NOTIFICATION_CHANNEL = "Recommendations"; - - private Context mContext; - - private int mId; - private int mPriority; - private int mSmallIcon; - private String mTitle; - private String mDescription; - private Bitmap mBitmap; - private String mBackgroundUri; - private PendingIntent mIntent; - - public RecommendationBuilder() { - } - - public RecommendationBuilder setContext(Context context) { - mContext = context; - return this; - } - - public RecommendationBuilder setId(int id) { - mId = id; - return this; - } - - public RecommendationBuilder setPriority(int priority) { - mPriority = priority; - return this; - } - - public RecommendationBuilder setTitle(String title) { - mTitle = title; - return this; - } - - public RecommendationBuilder setDescription(String description) { - mDescription = description; - return this; - } - - public RecommendationBuilder setBitmap(Bitmap bitmap) { - mBitmap = bitmap; - return this; - } - - public RecommendationBuilder setBackground(String uri) { - mBackgroundUri = uri; - return this; - } - - public RecommendationBuilder setIntent(PendingIntent intent) { - mIntent = intent; - return this; - } - - public RecommendationBuilder setSmallIcon(int resourceId) { - mSmallIcon = resourceId; - return this; - } - - public Notification build() { - - Log.d(TAG, "Building notification - " + this.toString()); - - Bundle extras = new Bundle(); - if (mBackgroundUri != null) { - try { - Log.d(TAG, "Background - " + Uri.parse(RecommendationContentProvider.CONTENT_URI + URLEncoder.encode(mBackgroundUri, "UTF-8") + "/0").toString()); - extras.putString(Notification.EXTRA_BACKGROUND_IMAGE_URI, Uri.parse(RecommendationContentProvider.CONTENT_URI + URLEncoder.encode(mBackgroundUri, "UTF-8")).toString()); - } catch (UnsupportedEncodingException e) { - e.printStackTrace(); - } - } - - Notification notification = new NotificationCompat.BigPictureStyle( - new NotificationCompat.Builder(mContext, NOTIFICATION_CHANNEL) - .setContentTitle(mTitle) - .setContentText(mDescription) - .setPriority(mPriority) - .setLocalOnly(true) - .setOngoing(true) - .setColor(Utils.getBrandColor(mContext)) - .setCategory(Notification.CATEGORY_RECOMMENDATION) - .setLargeIcon(mBitmap) - .setSmallIcon(mSmallIcon) - .setContentIntent(mIntent) - .setExtras(extras)) - .build(); - - return notification; - } - - @Override - public String toString() { - return "RecommendationBuilder{" + - ", mId=" + mId + - ", mPriority=" + mPriority + - ", mSmallIcon=" + mSmallIcon + - ", mTitle='" + mTitle + '\'' + - ", mDescription='" + mDescription + '\'' + - ", mBitmap='" + mBitmap + '\'' + - ", mBackgroundUri='" + mBackgroundUri + '\'' + - ", mIntent=" + mIntent + - '}'; - }} diff --git a/app/src/main/java/org/jellyfin/androidtv/integration/RecommendationContentProvider.java b/app/src/main/java/org/jellyfin/androidtv/integration/RecommendationContentProvider.java deleted file mode 100644 index 54b27cca7..000000000 --- a/app/src/main/java/org/jellyfin/androidtv/integration/RecommendationContentProvider.java +++ /dev/null @@ -1,157 +0,0 @@ -package org.jellyfin.androidtv.integration; -/** - * The MIT License (MIT) - * Copyright (c) 2014 David Carver - * Permission is hereby granted, free of charge, to any person obtaining - * a copy of this software and associated documentation files (the - * "Software"), to deal in the Software without restriction, including - * without limitation the rights to use, copy, modify, merge, publish, - * distribute, sublicense, and/or sell copies of the Software, and to - * permit persons to whom the Software is furnished to do so, subject to - * the following conditions: - * - * The above copyright notice and this permission notice shall be included - * in all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS - * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS - * OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, - * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF - * OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -import android.content.ContentProvider; -import android.content.ContentValues; -import android.database.Cursor; -import android.net.Uri; -import android.os.ParcelFileDescriptor; -import android.util.Log; - -import com.squareup.okhttp.OkHttpClient; -import com.squareup.okhttp.OkUrlFactory; - -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.net.HttpURLConnection; -import java.net.URL; -import java.net.URLDecoder; - -/** - * Provides a background image for Recommendations for a RecommendationCardView. - * - * The card view changed to require a content provider be created. It requests - * items via the openFile(Uri uri) method. This is an example of how to retrieve - * an image from a web site and provide a ParcelFileDescriptor back that - * contains the contents. - * - * This is based on code from the following stackoverflow description on how to - * populate a ParcelFileDescriptor from any input stream. - * - * http://stackoverflow.com/a/14734310/1950264 - * - * You still need to setup a ContentProvider entry and Authority in the - * AndroidManifest.xml - * - * See - * http://developer.android.com/reference/android/content/ContentProvider.html - * - */ -public class RecommendationContentProvider extends ContentProvider { - - public static String AUTHORITY = "org.jellyfin.androidtv.recommendations"; - public static String CONTENT_URI = "content://" + AUTHORITY + "/"; - - @Override - public boolean onCreate() { - return true; - } - - @Override - public ParcelFileDescriptor openFile(Uri uri, String mode) - throws FileNotFoundException { - ParcelFileDescriptor[] pipe = null; - - String url = uri.getPath(); - - try { - String decodedUrl = URLDecoder.decode(url.replaceFirst("/", ""), - "UTF-8"); - pipe = ParcelFileDescriptor.createPipe(); - - // This uses OKHttp to - OkHttpClient httpClient = new OkHttpClient(); - HttpURLConnection connection = new OkUrlFactory(httpClient).open(new URL(decodedUrl)); - - new TransferThread(connection.getInputStream(), - new ParcelFileDescriptor.AutoCloseOutputStream(pipe[1])) - .start(); - } catch (IOException e) { - Log.e(getClass().getSimpleName(), "Exception opening pipe", e); - throw new FileNotFoundException("Could not open pipe for: " - + uri.toString()); - } - - return (pipe[0]); - } - - @Override - public Cursor query(Uri uri, String[] projection, String selection, - String[] selectionArgs, String sortOrder) { - return null; - } - - @Override - public String getType(Uri uri) { - return "image/*"; - } - - @Override - public Uri insert(Uri uri, ContentValues values) { - return null; - } - - @Override - public int delete(Uri uri, String selection, String[] selectionArgs) { - return 0; - } - - @Override - public int update(Uri uri, ContentValues values, String selection, - String[] selectionArgs) { - return 0; - } - - static class TransferThread extends Thread { - InputStream in; - OutputStream out; - - TransferThread(InputStream in, OutputStream out) { - this.in = in; - this.out = out; - } - - @Override - public void run() { - byte[] buf = new byte[8192]; - int len; - - try { - while ((len = in.read(buf)) > 0) { - out.write(buf, 0, len); - } - - in.close(); - out.flush(); - out.close(); - } catch (IOException e) { - Log.e(getClass().getSimpleName(), - "Exception transferring file", e); - } - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/org/jellyfin/androidtv/integration/RecommendationManager.java b/app/src/main/java/org/jellyfin/androidtv/integration/RecommendationManager.java deleted file mode 100644 index d3c6d0e2c..000000000 --- a/app/src/main/java/org/jellyfin/androidtv/integration/RecommendationManager.java +++ /dev/null @@ -1,332 +0,0 @@ -package org.jellyfin.androidtv.integration; - -import android.app.Notification; -import android.app.NotificationManager; -import android.app.PendingIntent; -import android.app.TaskStackBuilder; -import android.content.Context; -import android.content.Intent; -import android.os.AsyncTask; -import android.os.Build; - -import org.jellyfin.androidtv.R; -import org.jellyfin.androidtv.TvApp; -import org.jellyfin.androidtv.browsing.MainActivity; -import org.jellyfin.androidtv.querying.StdItemQuery; -import org.jellyfin.androidtv.startup.StartupActivity; -import org.jellyfin.androidtv.util.ImageUtils; -import org.jellyfin.androidtv.util.Utils; - -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; - -import org.jellyfin.apiclient.interaction.Response; -import org.jellyfin.apiclient.model.dto.BaseItemDto; -import org.jellyfin.apiclient.model.entities.LocationType; -import org.jellyfin.apiclient.model.entities.SortOrder; -import org.jellyfin.apiclient.model.querying.ItemFields; -import org.jellyfin.apiclient.model.querying.ItemFilter; -import org.jellyfin.apiclient.model.querying.ItemSortBy; -import org.jellyfin.apiclient.model.querying.ItemsResult; -import org.jellyfin.apiclient.model.querying.NextUpQuery; -import org.jellyfin.apiclient.model.querying.SimilarItemsQuery; - -/** - * Created by Eric on 3/1/2015. - */ -public class RecommendationManager { - private final String REC_FILE_NAME = "reccomendations.json"; - private final Integer MAX_TV_RECS = 3; - private final Integer MAX_MOVIE_RECS = 4; - - private boolean isEnabled; - - private static RecommendationManager instance; - - private Recommendations mRecommendations; - - public RecommendationManager() { - isEnabled = Build.VERSION.SDK_INT >= 21; - mRecommendations = loadRecs(); - if (isEnabled) { - validate(); - } else { - TvApp.getApplication().getLogger().Info("Recommendations not enabled on this device"); - } - } - - public static RecommendationManager getInstance() { - if (instance == null) instance = new RecommendationManager(); - return instance; - } - - public static void init() { - if (instance == null) { - getInstance(); - } else { - instance.validate(); - } - } - - private Recommendations loadRecs() { - if (isEnabled) { - try { - InputStream recFile = TvApp.getApplication().openFileInput(REC_FILE_NAME); - String json = Utils.readStringFromStream(recFile); - recFile.close(); - return (Recommendations) TvApp.getApplication().getSerializer().DeserializeFromString(json, Recommendations.class); - } catch (IOException e) { - // none saved - return new Recommendations(TvApp.getApplication().getApiClient().getServerInfo().getId(), TvApp.getApplication().getCurrentUser().getId()); - } - } else { - return new Recommendations(TvApp.getApplication().getApiClient().getServerInfo().getId(), TvApp.getApplication().getCurrentUser().getId()); - } - } - - public boolean validate() { - if (isEnabled) { - //Now validate that these are for this server and user. - if (mRecommendations == null || mRecommendations.getServerId() == null || TvApp.getApplication().getApiClient().getServerInfo() == null || !mRecommendations.getServerId().equals(TvApp.getApplication().getApiClient().getServerInfo().getId()) - || mRecommendations.getUserId() == null || TvApp.getApplication().getCurrentUser() == null || !mRecommendations.getUserId().equals(TvApp.getApplication().getCurrentUser().getId())) { - //Nope - clear them out and start over for this user - clearAll(); - createAll(); - TvApp.getApplication().getLogger().Info("Recommendations re-set for user "+TvApp.getApplication().getCurrentUser().getName()); - return false; - } - - createAll(); - TvApp.getApplication().getLogger().Info("Recommendations re-created for user "+TvApp.getApplication().getCurrentUser().getName()); - - } - - return true; - } - - private void saveRecs() { - if (isEnabled) { - try { - OutputStream recFile = TvApp.getApplication().openFileOutput(REC_FILE_NAME, Context.MODE_PRIVATE); - recFile.write(TvApp.getApplication().getSerializer().SerializeToString(mRecommendations).getBytes()); - recFile.close(); - } catch (IOException e) { - TvApp.getApplication().getLogger().ErrorException("Error saving recommendations",e); - } - } - - } - - public void clearAll() { - NotificationManager nm = (NotificationManager) - TvApp.getApplication().getSystemService(Context.NOTIFICATION_SERVICE); - - nm.cancelAll(); - mRecommendations = new Recommendations(TvApp.getApplication().getApiClient().getServerInfo().getId(), TvApp.getApplication().getCurrentUser().getId()); - saveRecs(); - } - - public void createAll() { - if (isEnabled) { - //Create recs for next up tv and movies - NextUpQuery nextUpQuery = new NextUpQuery(); - nextUpQuery.setUserId(TvApp.getApplication().getCurrentUser().getId()); - nextUpQuery.setLimit(MAX_TV_RECS); - nextUpQuery.setFields(new ItemFields[] {ItemFields.PrimaryImageAspectRatio}); - TvApp.getApplication().getApiClient().GetNextUpEpisodesAsync(nextUpQuery, new Response() { - @Override - public void onResponse(ItemsResult response) { - if (response.getTotalRecordCount() > 0) { - for (BaseItemDto episode : response.getItems()) { - if (episode.getLocationType() != LocationType.Virtual) addRecommendation(episode, RecommendationType.Tv); - } - } - } - }); - - //First try for resumables - StdItemQuery resumeMovies = new StdItemQuery(); - resumeMovies.setIncludeItemTypes(new String[]{"Movie"}); - resumeMovies.setRecursive(true); - resumeMovies.setLimit(2); - resumeMovies.setFilters(new ItemFilter[]{ItemFilter.IsResumable}); - resumeMovies.setSortBy(new String[]{ItemSortBy.DatePlayed}); - resumeMovies.setSortOrder(SortOrder.Descending); - TvApp.getApplication().getApiClient().GetItemsAsync(resumeMovies, new Response() { - @Override - public void onResponse(ItemsResult response) { - int movieItems = 0; - if (response.getTotalRecordCount() > 0) { - for (BaseItemDto movie : response.getItems()) { - recommend(movie.getId()); - movieItems++; - } - } - if (movieItems < MAX_MOVIE_RECS) { - //Now fill in with latest movies - StdItemQuery suggMovies = new StdItemQuery(); - suggMovies.setIncludeItemTypes(new String[]{"Movie"}); - suggMovies.setRecursive(true); - suggMovies.setLimit(MAX_MOVIE_RECS - movieItems); - suggMovies.setFilters(new ItemFilter[]{ItemFilter.IsUnplayed}); - suggMovies.setSortBy(new String[]{ItemSortBy.DateCreated}); - suggMovies.setSortOrder(SortOrder.Descending); - TvApp.getApplication().getApiClient().GetItemsAsync(suggMovies, new Response() { - @Override - public void onResponse(ItemsResult suggResponse) { - if (suggResponse.getTotalRecordCount() > 0) { - for (BaseItemDto movie : suggResponse.getItems()) { - addRecommendation(movie, RecommendationType.Movie); - } - } - } - }); - } - } - }); - } - - } - - public void recommend(final String itemId) { - if (isEnabled) { - if (itemId == null) { - TvApp.getApplication().getLogger().Error("Attempt to recommend null Item"); - return; - } - //No matter what it is, if it is resumable, recommend this item (need to re-retrieve for current user data) - TvApp.getApplication().getApiClient().GetItemAsync(itemId, TvApp.getApplication().getCurrentUser().getId(), new Response() { - @Override - public void onResponse(BaseItemDto response) { - if (response == null) { - TvApp.getApplication().getLogger().Error("No item found with ID: "+itemId); - return; - } - if (response.getCanResume()) { - addRecommendation(response, RecommendationType.Movie); - } else { - switch (response.getBaseItemType()) { - case Movie: - //First remove us if we were a recommendation - mRecommendations.remove(RecommendationType.Movie, response.getId()); - - //Suggest a similar movie - SimilarItemsQuery similar = new SimilarItemsQuery(); - similar.setId(response.getId()); - similar.setLimit(1); - similar.setUserId(TvApp.getApplication().getCurrentUser().getId()); - TvApp.getApplication().getApiClient().GetSimilarItems(similar, new Response() { - @Override - public void onResponse(ItemsResult similarResponse) { - if (similarResponse.getTotalRecordCount() > 0) { - addRecommendation(similarResponse.getItems()[0], RecommendationType.Movie); - } - } - - @Override - public void onError(Exception exception) { - TvApp.getApplication().getLogger().ErrorException("Error retrieving item for recommendation", exception); - } - }); - break; - case Episode: - //First remove us if we were a recommendation - mRecommendations.remove(RecommendationType.Tv, response.getId()); - - //Suggest the next up episode - NextUpQuery next = new NextUpQuery(); - next.setSeriesId(response.getSeriesId()); - next.setUserId(TvApp.getApplication().getCurrentUser().getId()); - next.setLimit(1); - TvApp.getApplication().getApiClient().GetNextUpEpisodesAsync(next, new Response() { - @Override - public void onResponse(ItemsResult nextResponse) { - if (nextResponse.getTotalRecordCount() > 0 && nextResponse.getItems()[0].getLocationType() != LocationType.Virtual) { - addRecommendation(nextResponse.getItems()[0], RecommendationType.Tv); - } - } - - @Override - public void onError(Exception exception) { - TvApp.getApplication().getLogger().ErrorException("Error retrieving item for recommendation", exception); - } - }); - break; - } - - } - } - - @Override - public void onError(Exception exception) { - TvApp.getApplication().getLogger().ErrorException("Error retrieving item for recommendation", exception); - } - }); - } - - } - - public boolean addRecommendation(BaseItemDto item, RecommendationType type) { - if (isEnabled) { - //No need if we are already there - if (mRecommendations.get(type, item.getId()) != null) return false; - - //Not so build one - new AsyncRunner().execute(item, type); - return true; - } else { - return false; - } - - } - - private class AsyncRunner extends AsyncTask { - @Override - protected Boolean doInBackground(Object... params) { - BaseItemDto item = (BaseItemDto) params[0]; - RecommendationType type = (RecommendationType) params[1]; - Recommendation rec = new Recommendation(type, item.getId()); - rec.setRecId(mRecommendations.getRecId(type, type == RecommendationType.Movie ? MAX_MOVIE_RECS : MAX_TV_RECS)); - RecommendationBuilder builder = new RecommendationBuilder() - .setContext(TvApp.getApplication()) - .setSmallIcon(R.drawable.app_icon_medium); - - Notification recommendation = builder - .setId(rec.getRecId()) - .setPriority(0) - .setTitle(item.getName()) - .setDescription(item.getOverview()) - .setBitmap(Utils.getBitmapFromURL(ImageUtils.getPrimaryImageUrl(item, TvApp.getApplication().getApiClient(), true, true, false, 300))) - .setBackground(ImageUtils.getBackdropImageUrl(item, TvApp.getApplication().getApiClient(), true)) - .setIntent(buildPendingIntent(item)) - .build(); - - mRecommendations.add(rec); - saveRecs(); - - NotificationManager notificationManager = (NotificationManager) - TvApp.getApplication().getSystemService(Context.NOTIFICATION_SERVICE); - notificationManager.notify(rec.getRecId(), recommendation); - - return true; - } - } - - private PendingIntent buildPendingIntent(BaseItemDto item) { - Intent directIntent = new Intent(TvApp.getApplication(), StartupActivity.class); - directIntent.putExtra("ItemId", item.getId()); - directIntent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY); - - TaskStackBuilder stackBuilder = TaskStackBuilder.create(TvApp.getApplication()); - stackBuilder.addParentStack(MainActivity.class); - stackBuilder.addNextIntent(directIntent); - // Ensure a unique PendingIntents, otherwise all recommendations end up with the same - // PendingIntent - directIntent.setAction(item.getId()); - - return stackBuilder.getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT); - } - -} - diff --git a/app/src/main/java/org/jellyfin/androidtv/integration/RecommendationType.java b/app/src/main/java/org/jellyfin/androidtv/integration/RecommendationType.java deleted file mode 100644 index ae0e0c9ab..000000000 --- a/app/src/main/java/org/jellyfin/androidtv/integration/RecommendationType.java +++ /dev/null @@ -1,6 +0,0 @@ -package org.jellyfin.androidtv.integration; - -public enum RecommendationType { - Movie, - Tv -} diff --git a/app/src/main/java/org/jellyfin/androidtv/integration/Recommendations.java b/app/src/main/java/org/jellyfin/androidtv/integration/Recommendations.java deleted file mode 100644 index d9dbadf59..000000000 --- a/app/src/main/java/org/jellyfin/androidtv/integration/Recommendations.java +++ /dev/null @@ -1,129 +0,0 @@ -package org.jellyfin.androidtv.integration; - -import android.app.NotificationManager; -import android.content.Context; - -import org.jellyfin.androidtv.TvApp; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.Comparator; -import java.util.List; - -/** - * Created by Eric on 3/1/2015. - */ -public class Recommendations { - - private String serverId; - private String userId; - private List tvRecommendations = new ArrayList<>(); - private List movieRecommendations = new ArrayList<>(); - - public Recommendations(String serverId, String userId) { - this.userId = userId; - this.serverId = serverId; - } - - public List getTvRecommendations() { - return tvRecommendations; - } - - public void setTvRecommendations(List tvRecommendations) { - this.tvRecommendations = tvRecommendations; - } - - public List getMovieRecommendations() { - return movieRecommendations; - } - - public void setMovieRecommendations(List movieRecommendations) { - this.movieRecommendations = movieRecommendations; - } - - public Recommendation get(RecommendationType type, String id) { - Recommendation compare = new Recommendation(type, id); - switch (type) { - - case Movie: - return movieRecommendations.contains(compare) ? movieRecommendations.get(movieRecommendations.indexOf(compare)) : null; - case Tv: - return tvRecommendations.contains(compare) ? tvRecommendations.get(tvRecommendations.indexOf(compare)) : null; - } - - return null; - } - - public boolean add(Recommendation rec) { - switch (rec.getType()) { - - case Movie: - movieRecommendations.add(rec); - break; - case Tv: - tvRecommendations.add(rec); - break; - } - - return true; - } - - public Integer getRecId(RecommendationType type, int max) { - switch (type) { - case Movie: - if (movieRecommendations.size() < max) return 100 + movieRecommendations.size()+1; - return replaceOldest(type); - case Tv: - if (tvRecommendations.size() < max) return 200 + tvRecommendations.size()+1; - return replaceOldest(type); - } - - throw new IllegalArgumentException("type"); - } - - private Integer replaceOldest(RecommendationType type) { - List list = type == RecommendationType.Movie ? movieRecommendations : tvRecommendations; - - Recommendation oldest = Collections.min(list, new Comparator() { - @Override - public int compare(Recommendation lhs, Recommendation rhs) { - return Long.compare(lhs.getDateAdded(), rhs.getDateAdded()); - } - }); - - list.remove(oldest); - ((NotificationManager) TvApp.getApplication().getSystemService(Context.NOTIFICATION_SERVICE)).cancel(oldest.getRecId()); - return oldest.getRecId(); - } - - public boolean remove(RecommendationType type, String id) { - List list = type == RecommendationType.Movie ? movieRecommendations : tvRecommendations; - - Recommendation existing = get(type, id); - - if (existing != null) { - list.remove(existing); - ((NotificationManager) TvApp.getApplication().getSystemService(Context.NOTIFICATION_SERVICE)).cancel(existing.getRecId()); - return true; - } - - return false; - } - - public String getUserId() { - return userId; - } - - public void setUserId(String userId) { - this.userId = userId; - } - - public String getServerId() { - return serverId; - } - - public void setServerId(String serverId) { - this.serverId = serverId; - } -} - diff --git a/app/src/main/java/org/jellyfin/androidtv/playback/CustomPlaybackOverlayFragment.java b/app/src/main/java/org/jellyfin/androidtv/playback/CustomPlaybackOverlayFragment.java index 60f9123d1..ac48b6396 100644 --- a/app/src/main/java/org/jellyfin/androidtv/playback/CustomPlaybackOverlayFragment.java +++ b/app/src/main/java/org/jellyfin/androidtv/playback/CustomPlaybackOverlayFragment.java @@ -52,7 +52,6 @@ import org.jellyfin.androidtv.R; import org.jellyfin.androidtv.TvApp; import org.jellyfin.androidtv.base.CustomMessage; import org.jellyfin.androidtv.base.IMessageListener; -import org.jellyfin.androidtv.integration.RecommendationManager; import org.jellyfin.androidtv.itemhandling.BaseRowItem; import org.jellyfin.androidtv.itemhandling.ItemRowAdapter; import org.jellyfin.androidtv.livetv.ILiveTvGuide; @@ -85,6 +84,8 @@ import java.util.Date; import java.util.List; import org.jellyfin.androidtv.util.apiclient.BaseItemUtils; +import org.jellyfin.androidtv.util.apiclient.BaseItemUtils; +import org.jellyfin.androidtv.util.apiclient.StreamHelper; import org.jellyfin.apiclient.interaction.EmptyResponse; import org.jellyfin.apiclient.interaction.Response; import org.jellyfin.apiclient.model.dto.BaseItemDto; @@ -94,6 +95,24 @@ import org.jellyfin.apiclient.model.livetv.SeriesTimerInfoDto; import org.jellyfin.apiclient.model.mediainfo.SubtitleTrackEvent; import org.jellyfin.apiclient.model.mediainfo.SubtitleTrackInfo; +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Date; +import java.util.List; + +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.leanback.app.RowsSupportFragment; +import androidx.leanback.widget.ArrayObjectAdapter; +import androidx.leanback.widget.HeaderItem; +import androidx.leanback.widget.ListRow; +import androidx.leanback.widget.OnItemViewClickedListener; +import androidx.leanback.widget.Presenter; +import androidx.leanback.widget.Row; +import androidx.leanback.widget.RowPresenter; + public class CustomPlaybackOverlayFragment extends Fragment implements IPlaybackOverlayFragment, ILiveTvGuide { ImageView mLogoImage; View mTopPanel; @@ -735,8 +754,6 @@ public class CustomPlaybackOverlayFragment extends Fragment implements IPlayback public void onDestroy() { super.onDestroy(); if (mPlaybackController != null) { - if (mPlaybackController.getCurrentlyPlayingItem() != null) - RecommendationManager.getInstance().recommend(mPlaybackController.getCurrentlyPlayingItem().getId()); mPlaybackController.removePreviousQueueItems(); } } diff --git a/app/src/main/java/org/jellyfin/androidtv/playback/ExternalPlayerActivity.java b/app/src/main/java/org/jellyfin/androidtv/playback/ExternalPlayerActivity.java index c155db01c..feee6d721 100644 --- a/app/src/main/java/org/jellyfin/androidtv/playback/ExternalPlayerActivity.java +++ b/app/src/main/java/org/jellyfin/androidtv/playback/ExternalPlayerActivity.java @@ -134,7 +134,7 @@ public class ExternalPlayerActivity extends FragmentActivity { @Override public void onClick(DialogInterface dialog, int which) { SharedPreferences prefs = mApplication.getPrefs(); - prefs.edit().putBoolean("pref_video_use_external", false).apply(); + prefs.edit().putString("pref_video_player", "auto").apply(); prefs.edit().putBoolean("pref_live_tv_use_external", false).apply(); } }) diff --git a/app/src/main/java/org/jellyfin/androidtv/playback/MediaManager.java b/app/src/main/java/org/jellyfin/androidtv/playback/MediaManager.java index eb965f125..32261cd44 100644 --- a/app/src/main/java/org/jellyfin/androidtv/playback/MediaManager.java +++ b/app/src/main/java/org/jellyfin/androidtv/playback/MediaManager.java @@ -7,14 +7,18 @@ import android.content.Context; import android.content.DialogInterface; import android.content.Intent; import android.media.AudioManager; -import android.media.MediaPlayer; import android.net.Uri; +import android.os.Handler; import android.text.InputType; import android.widget.EditText; -import com.devbrackets.android.exomedia.EMAudioPlayer; -import com.devbrackets.android.exomedia.event.EMMediaProgressEvent; -import com.devbrackets.android.exomedia.listener.EMProgressCallback; +import com.google.android.exoplayer2.ExoPlaybackException; +import com.google.android.exoplayer2.ExoPlayerFactory; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.source.ProgressiveMediaSource; +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; import org.jellyfin.androidtv.R; import org.jellyfin.androidtv.TvApp; @@ -32,6 +36,12 @@ import org.jellyfin.androidtv.util.RemoteControlReceiver; import org.jellyfin.androidtv.util.Utils; import org.jellyfin.androidtv.util.apiclient.BaseItemUtils; import org.jellyfin.androidtv.util.apiclient.ReportingHelper; +import org.jellyfin.apiclient.interaction.ApiClient; +import org.jellyfin.apiclient.interaction.Response; +import org.jellyfin.apiclient.model.dlna.DeviceProfile; +import org.jellyfin.apiclient.model.dto.BaseItemDto; +import org.jellyfin.apiclient.model.playlists.PlaylistCreationRequest; +import org.jellyfin.apiclient.model.playlists.PlaylistCreationResult; import org.videolan.libvlc.LibVLC; import org.videolan.libvlc.Media; @@ -41,13 +51,6 @@ import java.util.Collections; import java.util.Date; import java.util.List; -import org.jellyfin.apiclient.interaction.ApiClient; -import org.jellyfin.apiclient.interaction.Response; -import org.jellyfin.apiclient.model.dlna.DeviceProfile; -import org.jellyfin.apiclient.model.dto.BaseItemDto; -import org.jellyfin.apiclient.model.playlists.PlaylistCreationRequest; -import org.jellyfin.apiclient.model.playlists.PlaylistCreationResult; - public class MediaManager { private static ItemRowAdapter mCurrentMediaAdapter; private static int mCurrentMediaPosition = -1; @@ -63,7 +66,7 @@ public class MediaManager { private static LibVLC mLibVLC; private static org.videolan.libvlc.MediaPlayer mVlcPlayer; private static VlcEventHandler mVlcHandler = new VlcEventHandler(); - private static EMAudioPlayer mExoplayer; + private static SimpleExoPlayer mExoPlayer; private static AudioManager mAudioManager; private static boolean audioInitialized; private static boolean nativeMode = false; @@ -157,7 +160,7 @@ public class MediaManager { } private static boolean isPaused() { - return nativeMode ? !mExoplayer.isPlaying() : !mVlcPlayer.isPlaying(); + return nativeMode ? !mExoPlayer.isPlaying() : !mVlcPlayer.isPlaying(); } private static void reportProgress() { @@ -165,7 +168,7 @@ public class MediaManager { if (System.currentTimeMillis() < lastProgressEvent + 750) return; lastProgressEvent = System.currentTimeMillis(); - mCurrentAudioPosition = nativeMode ? mExoplayer.getCurrentPosition() : mVlcPlayer.getTime(); + mCurrentAudioPosition = nativeMode ? mExoPlayer.getCurrentPosition() : mVlcPlayer.getTime(); //fire external listeners if there for (AudioEventListener listener : mAudioEventListeners) { @@ -199,19 +202,21 @@ public class MediaManager { // Create a new media player based on platform if (DeviceUtils.is60()) { nativeMode = true; - mExoplayer = new EMAudioPlayer(TvApp.getApplication()); - mExoplayer.setProgressCallback(new EMProgressCallback() { + mExoPlayer = ExoPlayerFactory.newSimpleInstance(TvApp.getApplication()); + mExoPlayer.addListener(new Player.EventListener() { @Override - public boolean onProgressUpdated(EMMediaProgressEvent progressEvent) { - reportProgress(); - return false; + public void onPlayerStateChanged(boolean playWhenReady, int playbackState) { + if (playbackState == Player.STATE_READY) { + startProgressLoop(); + } else if (playbackState == Player.STATE_ENDED) { + onComplete(); + stopProgressLoop(); + } } - }); - mExoplayer.setOnCompletionListener(new MediaPlayer.OnCompletionListener() { @Override - public void onCompletion(MediaPlayer mp) { - onComplete(); + public void onPlayerError(ExoPlaybackException error) { + stopProgressLoop(); } }); } else { @@ -253,6 +258,25 @@ public class MediaManager { return true; } + private static Runnable progressLoop; + private static Handler mHandler = new Handler(); + private static void startProgressLoop() { + progressLoop = new Runnable() { + @Override + public void run() { + reportProgress(); + mHandler.postDelayed(this, 500); + } + }; + mHandler.post(progressLoop); + } + + private static void stopProgressLoop() { + if (progressLoop != null) { + mHandler.removeCallbacks(progressLoop); + } + } + private static AudioManager.OnAudioFocusChangeListener mAudioFocusChanged = new AudioManager.OnAudioFocusChangeListener() { @Override public void onAudioFocusChange(int focusChange) { @@ -454,7 +478,7 @@ public class MediaManager { } } - public static boolean isPlayingAudio() { return audioInitialized && (nativeMode ? mExoplayer.isPlaying() : mVlcPlayer.isPlaying()); } + public static boolean isPlayingAudio() { return audioInitialized && (nativeMode ? mExoPlayer.isPlaying() : mVlcPlayer.isPlaying()); } private static boolean ensureInitialized() { if (!audioInitialized) { @@ -564,8 +588,10 @@ public class MediaManager { mCurrentAudioQueuePosition = pos; mCurrentAudioPosition = 0; if (nativeMode) { - mExoplayer.setDataSource(TvApp.getApplication(), Uri.parse(response.ToUrl(apiClient.getApiUrl(), apiClient.getAccessToken()))); - mExoplayer.start(); + DataSource.Factory dataSourceFactory = new DefaultDataSourceFactory(TvApp.getApplication(), "ATV/ExoPlayer"); + + mExoPlayer.setPlayWhenReady(true); + mExoPlayer.prepare(new ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(Uri.parse(response.ToUrl(apiClient.getApiUrl(), apiClient.getAccessToken())))); } else { TvApp.getApplication().getLogger().Info("Playback attempt via VLC of " + response.getMediaUrl()); Media media = new Media(mLibVLC, Uri.parse(response.getMediaUrl())); @@ -667,7 +693,7 @@ public class MediaManager { if (mCurrentAudioQueue == null || (!mRepeat && mCurrentAudioQueue.size() == 0)) return -1; if (isPlayingAudio() && mCurrentAudioPosition > 10000) { //just back up to the beginning of current item - if (nativeMode) mExoplayer.seekTo(0); + if (nativeMode) mExoPlayer.seekTo(0); else mVlcPlayer.setTime(0); return mCurrentAudioQueuePosition; } @@ -689,7 +715,7 @@ public class MediaManager { } private static void stop() { - if (nativeMode) mExoplayer.stopPlayback(); + if (nativeMode) mExoPlayer.stop(true); else mVlcPlayer.stop(); } @@ -708,7 +734,7 @@ public class MediaManager { } private static void pause() { - if (nativeMode) mExoplayer.pause(); + if (nativeMode) mExoPlayer.setPlayWhenReady(false); else mVlcPlayer.pause(); } @@ -730,7 +756,7 @@ public class MediaManager { public static void resumeAudio() { if (mCurrentAudioItem != null) { ensureAudioFocus(); - if (nativeMode) mExoplayer.start(); + if (nativeMode) mExoPlayer.setPlayWhenReady(true); else mVlcPlayer.play(); updateCurrentAudioItemPlaying(true); ReportingHelper.reportStart(mCurrentAudioItem, mCurrentAudioPosition * 10000); diff --git a/app/src/main/java/org/jellyfin/androidtv/playback/PlaybackController.java b/app/src/main/java/org/jellyfin/androidtv/playback/PlaybackController.java index 9730dfb29..0a572f8eb 100644 --- a/app/src/main/java/org/jellyfin/androidtv/playback/PlaybackController.java +++ b/app/src/main/java/org/jellyfin/androidtv/playback/PlaybackController.java @@ -24,9 +24,6 @@ import org.jellyfin.androidtv.util.Utils; import org.jellyfin.androidtv.util.apiclient.PlaybackHelper; import org.jellyfin.androidtv.util.apiclient.ReportingHelper; import org.jellyfin.androidtv.util.apiclient.StreamHelper; - -import java.util.List; - import org.jellyfin.apiclient.interaction.ApiClient; import org.jellyfin.apiclient.interaction.Response; import org.jellyfin.apiclient.model.dlna.DeviceProfile; @@ -43,6 +40,8 @@ import org.jellyfin.apiclient.model.livetv.ChannelInfoDto; import org.jellyfin.apiclient.model.mediainfo.SubtitleTrackInfo; import org.jellyfin.apiclient.model.session.PlayMethod; +import java.util.List; + public class PlaybackController { // Frequency to report playback progress private final static long PROGRESS_REPORTING_INTERVAL = TimeUtils.secondsToMillis(3); @@ -91,7 +90,7 @@ public class PlaybackController { private boolean isLiveTv; private boolean directStreamLiveTv; private String liveTvChannelName = ""; - private boolean useVlc = false; + private boolean useVlc; private boolean vlcErrorEncountered; private boolean exoErrorEncountered; @@ -112,6 +111,9 @@ public class PlaybackController { refreshRateSwitchingEnabled = DeviceUtils.is60() && mApplication.getPrefs().getBoolean("pref_refresh_switching", false); if (refreshRateSwitchingEnabled) getDisplayModes(); + // Set default value for useVlc field + // when set to auto the default will be exoplayer + useVlc = mApplication.getPrefs().getString("pref_video_player", "auto").equals("vlc"); } public void init(VideoManager mgr, View spinner) { @@ -473,26 +475,38 @@ public class PlaybackController { vlcResponse.getMediaSource().getVideoStream().getWidth() > 1200); mApplication.getLogger().Info(useDeinterlacing ? "Explicit deinterlacing will be used" : "Explicit deinterlacing will NOT be used"); - // TODO: Clean up this logic - // Now look at both responses and choose the one that direct plays or bitstreams - favor VLC - useVlc = !vlcErrorEncountered && - !vlcResponse.getPlayMethod().equals(PlayMethod.Transcode) && - (DeviceUtils.is60() || - !mApplication.getPrefs().getBoolean("pref_bitstream_ac3", false) || - vlcResponse.getMediaSource() == null || - vlcResponse.getMediaSource().getDefaultAudioStream() == null || - (!"ac3".equals(vlcResponse.getMediaSource().getDefaultAudioStream().getCodec()) && - !"truehd".equals(vlcResponse.getMediaSource().getDefaultAudioStream().getCodec()))) && - (Utils.downMixAudio() || - !DeviceUtils.is60() || - internalResponse.getPlayMethod().equals(PlayMethod.Transcode) || - !mApplication.getPrefs().getBoolean("pref_bitstream_dts", false) || - internalResponse.getMediaSource() == null || - internalResponse.getMediaSource().getDefaultAudioStream() == null || - (!internalResponse.getMediaSource().getDefaultAudioStream().getCodec().equals("dca") && - !internalResponse.getMediaSource().getDefaultAudioStream().getCodec().equals("dts"))) && - (!DeviceUtils.isFireTvStick() || - (vlcResponse.getMediaSource().getVideoStream() != null && vlcResponse.getMediaSource().getVideoStream().getWidth() < 1000)); + String preferredVideoPlayer = mApplication.getPrefs().getString("pref_video_player", "auto"); + + mApplication.getLogger().Info("User preferred player is: " + preferredVideoPlayer); + + if (preferredVideoPlayer.equals("vlc")) { + // Force VLC + useVlc = true; + } else if (preferredVideoPlayer.equals("exoplayer")) { + // Make sure to not use VLC + useVlc = false; + } else if (preferredVideoPlayer.equals("auto")) { + // TODO: Clean up this logic + // Now look at both responses and choose the one that direct plays or bitstreams - favor VLC + useVlc = !vlcErrorEncountered && + !vlcResponse.getPlayMethod().equals(PlayMethod.Transcode) && + (DeviceUtils.is60() || + !mApplication.getPrefs().getBoolean("pref_bitstream_ac3", false) || + vlcResponse.getMediaSource() == null || + vlcResponse.getMediaSource().getDefaultAudioStream() == null || + (!"ac3".equals(vlcResponse.getMediaSource().getDefaultAudioStream().getCodec()) && + !"truehd".equals(vlcResponse.getMediaSource().getDefaultAudioStream().getCodec()))) && + (Utils.downMixAudio() || + !DeviceUtils.is60() || + internalResponse.getPlayMethod().equals(PlayMethod.Transcode) || + !mApplication.getPrefs().getBoolean("pref_bitstream_dts", false) || + internalResponse.getMediaSource() == null || + internalResponse.getMediaSource().getDefaultAudioStream() == null || + (!internalResponse.getMediaSource().getDefaultAudioStream().getCodec().equals("dca") && + !internalResponse.getMediaSource().getDefaultAudioStream().getCodec().equals("dts"))) && + (!DeviceUtils.isFireTvStick() || + (vlcResponse.getMediaSource().getVideoStream() != null && vlcResponse.getMediaSource().getVideoStream().getWidth() < 1000)); + } mApplication.getLogger().Info(useVlc ? "Preferring VLC" : "Will use internal player"); mVideoManager.init(getBufferAmount(), useDeinterlacing); @@ -524,7 +538,6 @@ public class PlaybackController { @Override public void onError(Exception exception) { mApplication.getLogger().ErrorException("Unable to get internal stream info", exception); - useVlc = true; mCurrentOptions = vlcOptions; startItem(item, position, vlcResponse); } @@ -568,7 +581,10 @@ public class PlaybackController { setPlaybackMethod(response.getPlayMethod()); - if (useVlc && (!getPlaybackMethod().equals(PlayMethod.Transcode) || isLiveTv)) { + // Force VLC when media is not live TV and the preferred player is VLC + boolean forceVlc = !isLiveTv && mApplication.getPrefs().getString("pref_video_player", "auto").equals("vlc"); + + if (forceVlc || (useVlc && (!getPlaybackMethod().equals(PlayMethod.Transcode) || isLiveTv))) { TvApp.getApplication().getLogger().Info("Playing back in VLC."); mVideoManager.setNativeMode(false); } else { diff --git a/app/src/main/java/org/jellyfin/androidtv/playback/VideoManager.java b/app/src/main/java/org/jellyfin/androidtv/playback/VideoManager.java index 08568076d..725bbb08d 100644 --- a/app/src/main/java/org/jellyfin/androidtv/playback/VideoManager.java +++ b/app/src/main/java/org/jellyfin/androidtv/playback/VideoManager.java @@ -3,7 +3,6 @@ package org.jellyfin.androidtv.playback; import android.app.Activity; import android.content.res.Configuration; import android.graphics.PixelFormat; -import android.media.MediaPlayer; import android.net.Uri; import android.os.Handler; import android.view.SurfaceHolder; @@ -14,12 +13,22 @@ import android.widget.FrameLayout; import androidx.leanback.media.PlayerAdapter; -import com.devbrackets.android.exomedia.EMVideoView; +import com.google.android.exoplayer2.ExoPlaybackException; +import com.google.android.exoplayer2.ExoPlayerFactory; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.source.ProgressiveMediaSource; +import com.google.android.exoplayer2.ui.PlayerView; +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; import org.jellyfin.androidtv.R; import org.jellyfin.androidtv.TvApp; import org.jellyfin.androidtv.util.DeviceUtils; import org.jellyfin.androidtv.util.Utils; +import org.jellyfin.apiclient.model.dto.MediaSourceInfo; +import org.jellyfin.apiclient.model.entities.MediaStream; +import org.jellyfin.apiclient.model.entities.MediaStreamType; import org.videolan.libvlc.IVLCVout; import org.videolan.libvlc.LibVLC; import org.videolan.libvlc.Media; @@ -27,10 +36,6 @@ import org.videolan.libvlc.Media; import java.util.ArrayList; import java.util.List; -import org.jellyfin.apiclient.model.dto.MediaSourceInfo; -import org.jellyfin.apiclient.model.entities.MediaStream; -import org.jellyfin.apiclient.model.entities.MediaStreamType; - public class VideoManager implements IVLCVout.OnNewVideoLayoutListener { public final static int ZOOM_NORMAL = 0; @@ -45,7 +50,8 @@ public class VideoManager implements IVLCVout.OnNewVideoLayoutListener { private SurfaceView mSurfaceView; private SurfaceView mSubtitlesSurface; private FrameLayout mSurfaceFrame; - private EMVideoView mVideoView; + private SimpleExoPlayer mExoPlayer; + private PlayerView mExoPlayerView; private LibVLC mLibVLC; private org.videolan.libvlc.MediaPlayer mVlcPlayer; private String mCurrentVideoPath; @@ -70,6 +76,9 @@ public class VideoManager implements IVLCVout.OnNewVideoLayoutListener { private boolean mSurfaceReady = false; public boolean isContracted = false; private boolean hasSubtitlesSurface = false; + private PlaybackListener errorListener; + private PlaybackListener completionListener; + private PlaybackListener preparedListener; public VideoManager(PlaybackOverlayActivity activity, View view) { mActivity = activity; @@ -85,7 +94,29 @@ public class VideoManager implements IVLCVout.OnNewVideoLayoutListener { } else { mSubtitlesSurface.setVisibility(View.GONE); } - mVideoView = view.findViewById(R.id.videoView); + mExoPlayer = ExoPlayerFactory.newSimpleInstance(TvApp.getApplication()); + mExoPlayerView = view.findViewById(R.id.exoPlayerView); + mExoPlayerView.setPlayer(mExoPlayer); + mExoPlayer.addListener(new Player.EventListener() { + @Override + public void onPlayerError(ExoPlaybackException error) { + TvApp.getApplication().getLogger().Error("***** Got error from player"); + if (errorListener != null) errorListener.onEvent(); + stopProgressLoop(); + } + + @Override + public void onPlayerStateChanged(boolean playWhenReady, int playbackState) { + // Do not call listener when paused + if (playbackState == Player.STATE_READY && playWhenReady) { + if (preparedListener != null) preparedListener.onEvent(); + startProgressLoop(); + } else if (playbackState == Player.STATE_ENDED) { + if (completionListener != null) completionListener.onEvent(); + stopProgressLoop(); + } + } + }); } public void init(int buffer, boolean isInterlaced) { @@ -95,9 +126,9 @@ public class VideoManager implements IVLCVout.OnNewVideoLayoutListener { public void setNativeMode(boolean value) { nativeMode = value; if (nativeMode) { - mVideoView.setVisibility(View.VISIBLE); + mExoPlayerView.setVisibility(View.VISIBLE); } else { - mVideoView.setVisibility(View.GONE); + mExoPlayerView.setVisibility(View.GONE); } } @@ -108,20 +139,20 @@ public class VideoManager implements IVLCVout.OnNewVideoLayoutListener { mZoomMode = mode; switch (mode) { case ZOOM_NORMAL: - mVideoView.setScaleY(1); - mVideoView.setScaleX(1); + mExoPlayerView.setScaleY(1); + mExoPlayerView.setScaleX(1); break; case ZOOM_VERTICAL: - mVideoView.setScaleX(1); - mVideoView.setScaleY(1.33f); + mExoPlayerView.setScaleX(1); + mExoPlayerView.setScaleY(1.33f); break; case ZOOM_HORIZONTAL: - mVideoView.setScaleY(1); - mVideoView.setScaleX(1.33f); + mExoPlayerView.setScaleY(1); + mExoPlayerView.setScaleX(1.33f); break; case ZOOM_FULL: - mVideoView.setScaleX(1.33f); - mVideoView.setScaleY(1.33f); + mExoPlayerView.setScaleX(1.33f); + mExoPlayerView.setScaleY(1.33f); break; } @@ -133,14 +164,14 @@ public class VideoManager implements IVLCVout.OnNewVideoLayoutListener { public long getDuration() { if (nativeMode){ - return mVideoView.getDuration() > 0 ? mVideoView.getDuration() : mMetaDuration; + return mExoPlayer.getDuration() > 0 ? mExoPlayer.getDuration() : mMetaDuration; } else { return mVlcPlayer.getLength() > 0 ? mVlcPlayer.getLength() : mMetaDuration; } } public long getCurrentPosition() { - if (nativeMode) return mVideoView.getCurrentPosition(); + if (nativeMode) return mExoPlayer.getCurrentPosition(); if (mVlcPlayer == null) return 0; @@ -164,17 +195,17 @@ public class VideoManager implements IVLCVout.OnNewVideoLayoutListener { } public boolean isPlaying() { - return nativeMode ? mVideoView.isPlaying() : mVlcPlayer != null && mVlcPlayer.isPlaying(); + return nativeMode ? mExoPlayer.isPlaying() : mVlcPlayer != null && mVlcPlayer.isPlaying(); } public boolean canSeek() { return nativeMode || mVlcPlayer.isSeekable(); } public void start() { if (nativeMode) { - mVideoView.start(); - mVideoView.setKeepScreenOn(true); - normalWidth = mVideoView.getLayoutParams().width; - normalHeight = mVideoView.getLayoutParams().height; + mExoPlayer.setPlayWhenReady(true); + mExoPlayerView.setKeepScreenOn(true); + normalWidth = mExoPlayerView.getLayoutParams().width; + normalHeight = mExoPlayerView.getLayoutParams().height; } else { if (!mSurfaceReady) { TvApp.getApplication().getLogger().Error("Attempt to play before surface ready"); @@ -190,8 +221,8 @@ public class VideoManager implements IVLCVout.OnNewVideoLayoutListener { public void play() { if (nativeMode) { - mVideoView.start(); - mVideoView.setKeepScreenOn(true); + mExoPlayer.setPlayWhenReady(true); + mExoPlayerView.setKeepScreenOn(true); } else { mVlcPlayer.play(); mSurfaceView.setKeepScreenOn(true); @@ -200,8 +231,8 @@ public class VideoManager implements IVLCVout.OnNewVideoLayoutListener { public void pause() { if (nativeMode) { - mVideoView.pause(); - mVideoView.setKeepScreenOn(false); + mExoPlayer.setPlayWhenReady(false); + mExoPlayerView.setKeepScreenOn(false); } else { mVlcPlayer.pause(); mSurfaceView.setKeepScreenOn(false); @@ -215,7 +246,7 @@ public class VideoManager implements IVLCVout.OnNewVideoLayoutListener { public void stopPlayback() { if (nativeMode) { - mVideoView.stopPlayback(); + mExoPlayer.stop(); } else { mVlcPlayer.stop(); } @@ -225,8 +256,8 @@ public class VideoManager implements IVLCVout.OnNewVideoLayoutListener { public long seekTo(long pos) { if (nativeMode) { Long intPos = pos; - TvApp.getApplication().getLogger().Info("Exo length in seek is: " + mVideoView.getDuration()); - mVideoView.seekTo(intPos.intValue()); + TvApp.getApplication().getLogger().Info("Exo length in seek is: " + mExoPlayer.getDuration()); + mExoPlayer.seekTo(intPos.intValue()); return pos; } else { if (mVlcPlayer == null || !mVlcPlayer.isSeekable()) return -1; @@ -257,7 +288,9 @@ public class VideoManager implements IVLCVout.OnNewVideoLayoutListener { if (nativeMode) { try { - mVideoView.setVideoPath(path); + DataSource.Factory dataSourceFactory = new DefaultDataSourceFactory(TvApp.getApplication(), "ATV/ExoPlayer"); + + mExoPlayer.prepare(new ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(Uri.parse(path))); } catch (IllegalStateException e) { TvApp.getApplication().getLogger().ErrorException("Unable to set video path. Probably backing out.", e); } @@ -275,7 +308,7 @@ public class VideoManager implements IVLCVout.OnNewVideoLayoutListener { public void hideSurface() { if (nativeMode) { - mVideoView.setVisibility(View.INVISIBLE); + mExoPlayerView.setVisibility(View.INVISIBLE); } else { mSurfaceView.setVisibility(View.INVISIBLE); } @@ -283,7 +316,7 @@ public class VideoManager implements IVLCVout.OnNewVideoLayoutListener { public void showSurface() { if (nativeMode) { - mVideoView.setVisibility(View.VISIBLE); + mExoPlayerView.setVisibility(View.VISIBLE); } else { mSurfaceView.setVisibility(View.VISIBLE); } @@ -472,15 +505,22 @@ public class VideoManager implements IVLCVout.OnNewVideoLayoutListener { } private void releasePlayer() { - if (mVlcPlayer == null) return; + if (mVlcPlayer != null) { + mVlcPlayer.setEventListener(null); + mVlcPlayer.stop(); + mVlcPlayer.getVLCVout().detachViews(); + mVlcPlayer.release(); + mLibVLC.release(); + mLibVLC = null; + mVlcPlayer = null; + } + + if (mExoPlayer != null) { + mExoPlayerView.setPlayer(null); + mExoPlayer.release(); + mExoPlayer = null; + } - mVlcPlayer.setEventListener(null); - mVlcPlayer.stop(); - mVlcPlayer.getVLCVout().detachViews(); - mVlcPlayer.release(); - mLibVLC.release(); - mLibVLC = null; - mVlcPlayer = null; mSurfaceView.setKeepScreenOn(false); } @@ -488,7 +528,7 @@ public class VideoManager implements IVLCVout.OnNewVideoLayoutListener { int normalHeight; public void contractVideo(int height) { - FrameLayout.LayoutParams lp = (FrameLayout.LayoutParams) (nativeMode ? mVideoView.getLayoutParams() : mSurfaceView.getLayoutParams()); + FrameLayout.LayoutParams lp = (FrameLayout.LayoutParams) (nativeMode ? mExoPlayerView.getLayoutParams() : mSurfaceView.getLayoutParams()); if (isContracted) return; Activity activity = TvApp.getApplication().getCurrentActivity(); @@ -501,8 +541,8 @@ public class VideoManager implements IVLCVout.OnNewVideoLayoutListener { lp.bottomMargin = ((lp.height - normalHeight) / 2) - 50; if (nativeMode) { - mVideoView.setLayoutParams(lp); - mVideoView.invalidate(); + mExoPlayerView.setLayoutParams(lp); + mExoPlayerView.invalidate(); } else mSurfaceView.setLayoutParams(lp); isContracted = true; @@ -511,14 +551,14 @@ public class VideoManager implements IVLCVout.OnNewVideoLayoutListener { public void setVideoFullSize() { if (normalHeight == 0) return; - FrameLayout.LayoutParams lp = (FrameLayout.LayoutParams) (nativeMode ? mVideoView.getLayoutParams() : mSurfaceView.getLayoutParams()); + FrameLayout.LayoutParams lp = (FrameLayout.LayoutParams) (nativeMode ? mExoPlayerView.getLayoutParams() : mSurfaceView.getLayoutParams()); lp.height = normalHeight; lp.width = normalWidth; if (nativeMode) { lp.rightMargin = 0; lp.bottomMargin = 0; - mVideoView.setLayoutParams(lp); - mVideoView.invalidate(); + mExoPlayerView.setLayoutParams(lp); + mExoPlayerView.invalidate(); } else mSurfaceView.setLayoutParams(lp); isContracted = false; @@ -594,19 +634,7 @@ public class VideoManager implements IVLCVout.OnNewVideoLayoutListener { if (hasSubtitlesSurface) mSubtitlesSurface.invalidate(); } - PlaybackListener errorListener; - public void setOnErrorListener(final PlaybackListener listener) { - mVideoView.setOnErrorListener(new MediaPlayer.OnErrorListener() { - @Override - public boolean onError(MediaPlayer mp, int what, int extra) { - TvApp.getApplication().getLogger().Error("***** Got error from player"); - listener.onEvent(); - stopProgressLoop(); - return true; - } - }); - mVlcHandler.setOnErrorListener(listener); errorListener = listener; } @@ -616,28 +644,12 @@ public class VideoManager implements IVLCVout.OnNewVideoLayoutListener { } public void setOnCompletionListener(final PlaybackListener listener) { - mVideoView.setOnCompletionListener(new MediaPlayer.OnCompletionListener() { - @Override - public void onCompletion(MediaPlayer mp) { - listener.onEvent(); - stopProgressLoop(); - } - }); - + completionListener = listener; mVlcHandler.setOnCompletionListener(listener); } - private MediaPlayer mNativeMediaPlayer; - public void setOnPreparedListener(final PlaybackListener listener) { - mVideoView.setOnPreparedListener(new MediaPlayer.OnPreparedListener() { - @Override - public void onPrepared(MediaPlayer mp) { - mNativeMediaPlayer = mp; - listener.onEvent(); - startProgressLoop(); - } - }); + preparedListener = listener; mVlcHandler.setOnPreparedListener(listener); } diff --git a/app/src/main/java/org/jellyfin/androidtv/search/LeanbackSearchFragment.java b/app/src/main/java/org/jellyfin/androidtv/search/LeanbackSearchFragment.java new file mode 100644 index 000000000..ad19ec7f0 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/search/LeanbackSearchFragment.java @@ -0,0 +1,29 @@ +package org.jellyfin.androidtv.search; + +import android.os.Bundle; + +import org.jellyfin.androidtv.itemhandling.BaseRowItem; +import org.jellyfin.androidtv.itemhandling.ItemLauncher; +import org.jellyfin.androidtv.itemhandling.ItemRowAdapter; + +import androidx.leanback.app.SearchSupportFragment; +import androidx.leanback.widget.ListRow; + +public class LeanbackSearchFragment extends SearchSupportFragment { + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // Create provider + SearchProvider searchProvider = new SearchProvider(getContext(), getActivity().getIntent().getBooleanExtra("MusicOnly", false)); + setSearchResultProvider(searchProvider); + + // Create click listener + setOnItemViewClickedListener((itemViewHolder, item, rowViewHolder, row) -> { + if (!(item instanceof BaseRowItem)) return; + + ItemLauncher.launch((BaseRowItem) item, (ItemRowAdapter) ((ListRow) row).getAdapter(), ((BaseRowItem) item).getIndex(), getActivity()); + }); + } +} + diff --git a/app/src/main/java/org/jellyfin/androidtv/search/MySearchFragment.java b/app/src/main/java/org/jellyfin/androidtv/search/MySearchFragment.java deleted file mode 100644 index 120f598bc..000000000 --- a/app/src/main/java/org/jellyfin/androidtv/search/MySearchFragment.java +++ /dev/null @@ -1,113 +0,0 @@ -package org.jellyfin.androidtv.search; - -import android.graphics.drawable.Drawable; -import android.os.Bundle; -import android.os.Handler; -import androidx.leanback.app.BackgroundManager; -import androidx.leanback.app.SearchFragment; -import androidx.leanback.app.SearchSupportFragment; -import androidx.leanback.widget.ArrayObjectAdapter; -import androidx.leanback.widget.ListRow; -import androidx.leanback.widget.ListRowPresenter; -import androidx.leanback.widget.ObjectAdapter; -import androidx.leanback.widget.OnItemViewClickedListener; -import androidx.leanback.widget.Presenter; -import androidx.leanback.widget.Row; -import androidx.leanback.widget.RowPresenter; -import android.util.DisplayMetrics; - -import com.squareup.picasso.Target; - -import org.jellyfin.androidtv.imagehandling.PicassoBackgroundManagerTarget; -import org.jellyfin.androidtv.itemhandling.BaseRowItem; -import org.jellyfin.androidtv.itemhandling.ItemLauncher; -import org.jellyfin.androidtv.itemhandling.ItemRowAdapter; -import org.jellyfin.androidtv.util.Utils; - -import java.util.Timer; - -/** - * Created by Eric on 1/26/2015. - */ -public class MySearchFragment extends SearchSupportFragment - implements SearchSupportFragment.SearchResultProvider { - - private static final int SEARCH_DELAY_MS = 1500; - private final Handler mHandler = new Handler(); - private ArrayObjectAdapter mRowsAdapter; - private SearchRunnable mDelayedLoad; - - private Drawable mDefaultBackground; - private Target mBackgroundTarget; - private DisplayMetrics mMetrics; - private Timer mBackgroundTimer; - private String mBackgroundUrl; - - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - mRowsAdapter = new ArrayObjectAdapter(new ListRowPresenter()); - setSearchResultProvider(this); - setOnItemViewClickedListener(new OnItemViewClickedListener() { - @Override - public void onItemClicked(Presenter.ViewHolder itemViewHolder, Object item, RowPresenter.ViewHolder rowViewHolder, Row row) { - if (!(item instanceof BaseRowItem)) return; - ItemLauncher.launch((BaseRowItem) item, (ItemRowAdapter) ((ListRow)row).getAdapter(),((BaseRowItem) item).getIndex(), getActivity()); - } - }); - mDelayedLoad = new SearchRunnable(getActivity(), mRowsAdapter, getActivity().getIntent().getBooleanExtra("MusicOnly",false)); - - prepareBackgroundManager(); - } - - @Override - public ObjectAdapter getResultsAdapter() { - return mRowsAdapter; - } - - private boolean textEdited = false; - - @Override - public boolean onQueryTextChange(String newQuery) { - textEdited = true; - if (Utils.isNonEmpty(newQuery)) { - mHandler.removeCallbacks(mDelayedLoad); - mDelayedLoad.setQueryString(newQuery); - mHandler.postDelayed(mDelayedLoad, SEARCH_DELAY_MS); - } else { - mRowsAdapter.clear(); - } - return true; - } - - @Override - public boolean onQueryTextSubmit(String query) { - if (!textEdited) { - if (Utils.isNonEmpty(query)) { - mHandler.removeCallbacks(mDelayedLoad); - mDelayedLoad.setQueryString(query); - mHandler.postDelayed(mDelayedLoad, SEARCH_DELAY_MS); - } else { - mRowsAdapter.clear(); - } - } - return true; - } - - private void prepareBackgroundManager() { - - BackgroundManager backgroundManager = BackgroundManager.getInstance(getActivity()); - backgroundManager.attach(getActivity().getWindow()); - mBackgroundTarget = new PicassoBackgroundManagerTarget(backgroundManager); - - mDefaultBackground = null; - - mMetrics = new DisplayMetrics(); - getActivity().getWindowManager().getDefaultDisplay().getMetrics(mMetrics); - } - - -} - diff --git a/app/src/main/java/org/jellyfin/androidtv/search/MySearchFragmentNoSpeech.java b/app/src/main/java/org/jellyfin/androidtv/search/MySearchFragmentNoSpeech.java deleted file mode 100644 index 028f639a8..000000000 --- a/app/src/main/java/org/jellyfin/androidtv/search/MySearchFragmentNoSpeech.java +++ /dev/null @@ -1,108 +0,0 @@ -package org.jellyfin.androidtv.search; - -import androidx.fragment.app.Fragment; -import android.os.Bundle; -import android.os.Handler; -import androidx.leanback.app.RowsSupportFragment; -import androidx.leanback.widget.ArrayObjectAdapter; -import androidx.leanback.widget.ListRow; -import androidx.leanback.widget.ListRowPresenter; -import androidx.leanback.widget.OnItemViewClickedListener; -import androidx.leanback.widget.Presenter; -import androidx.leanback.widget.Row; -import androidx.leanback.widget.RowPresenter; -import android.text.Editable; -import android.text.TextWatcher; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.EditText; - -import org.jellyfin.androidtv.R; -import org.jellyfin.androidtv.itemhandling.BaseRowItem; -import org.jellyfin.androidtv.itemhandling.ItemLauncher; -import org.jellyfin.androidtv.itemhandling.ItemRowAdapter; - -/** - * Created by Eric on 4/8/2015. - */ -public class MySearchFragmentNoSpeech extends Fragment { - - private static final int SEARCH_DELAY_MS = 1500; - - private RowsSupportFragment mRowsFragment; - private EditText mSearchField; - private ArrayObjectAdapter mRowsAdapter; - private SearchRunnable mDelayedLoad; - private Handler mHandler = new Handler(); - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - } - - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - - View root = inflater.inflate(R.layout.fragment_empty_frame, container, false); - - // Inject the RowsSupportFragment in the results container - if (getChildFragmentManager().findFragmentById(R.id.emptyFrame) == null) { - mRowsFragment = new RowsSupportFragment(); - getChildFragmentManager().beginTransaction() - .replace(R.id.emptyFrame, mRowsFragment).commit(); - } else { - mRowsFragment = (RowsSupportFragment) getChildFragmentManager() - .findFragmentById(R.id.emptyFrame); - } - - mRowsAdapter = new ArrayObjectAdapter(new ListRowPresenter()); - mRowsFragment.setAdapter(mRowsAdapter); - - // Setup item selection - mRowsFragment.setOnItemViewClickedListener(new OnItemViewClickedListener() { - @Override - public void onItemClicked(Presenter.ViewHolder itemViewHolder, Object item, RowPresenter.ViewHolder rowViewHolder, Row row) { - if (!(item instanceof BaseRowItem)) return; - ItemLauncher.launch((BaseRowItem) item, (ItemRowAdapter) ((ListRow)row).getAdapter(), ((BaseRowItem) item).getIndex(), getActivity()); - } - }); - - mDelayedLoad = new SearchRunnable(getActivity(), mRowsAdapter, getActivity().getIntent().getBooleanExtra("MusicOnly",false)); - - - return root; - } - - @Override - public void onActivityCreated(Bundle savedInstanceState) { - super.onActivityCreated(savedInstanceState); - - // Setup search field listening - mSearchField = (EditText) getActivity().findViewById(R.id.searchField); - mSearchField.addTextChangedListener(new TextWatcher() { - @Override - public void beforeTextChanged(CharSequence s, int start, int count, int after) { - - } - - @Override - public void onTextChanged(CharSequence s, int start, int before, int count) { - - } - - @Override - public void afterTextChanged(Editable s) { - if (s.length() > 0) { - mHandler.removeCallbacks(mDelayedLoad); - mDelayedLoad.setQueryString(s.toString()); - mHandler.postDelayed(mDelayedLoad, SEARCH_DELAY_MS); - - } else { - mRowsAdapter.clear(); - } - } - }); - - } -} diff --git a/app/src/main/java/org/jellyfin/androidtv/search/SearchActivity.java b/app/src/main/java/org/jellyfin/androidtv/search/SearchActivity.java index b9e2aae28..77820b61a 100644 --- a/app/src/main/java/org/jellyfin/androidtv/search/SearchActivity.java +++ b/app/src/main/java/org/jellyfin/androidtv/search/SearchActivity.java @@ -3,26 +3,34 @@ package org.jellyfin.androidtv.search; import android.os.Bundle; import android.speech.SpeechRecognizer; -import org.jellyfin.androidtv.R; -import org.jellyfin.androidtv.TvApp; import org.jellyfin.androidtv.base.BaseActivity; +import androidx.fragment.app.Fragment; + public class SearchActivity extends BaseActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - if (TvApp.getApplication().isVoiceSearchAllowed() && - SpeechRecognizer.isRecognitionAvailable(TvApp.getApplication())) { - setContentView(R.layout.activity_search); - } else { - setContentView(R.layout.activity_search_no_speech); - } + + boolean isSpeechEnabled = SpeechRecognizer.isRecognitionAvailable(this); + + // Determine fragment to use + Fragment searchFragment = isSpeechEnabled + ? new LeanbackSearchFragment() + : new TextSearchFragment(); + + // Add fragment + getSupportFragmentManager() + .beginTransaction() + .replace(android.R.id.content, searchFragment) + .commit(); } @Override public boolean onSearchRequested() { - //re-start us - this.recreate(); + // Reset layout + recreate(); + return true; } } diff --git a/app/src/main/java/org/jellyfin/androidtv/search/SearchProvider.java b/app/src/main/java/org/jellyfin/androidtv/search/SearchProvider.java new file mode 100644 index 000000000..a6e63d847 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/search/SearchProvider.java @@ -0,0 +1,67 @@ +package org.jellyfin.androidtv.search; + +import android.content.Context; +import android.os.Handler; + +import org.jellyfin.androidtv.util.Utils; + +import androidx.leanback.app.SearchSupportFragment; +import androidx.leanback.widget.ArrayObjectAdapter; +import androidx.leanback.widget.ListRowPresenter; +import androidx.leanback.widget.ObjectAdapter; + +public class SearchProvider implements SearchSupportFragment.SearchResultProvider { + + private static final int SEARCH_DELAY_MS = 1500; + private final Handler mHandler = new Handler(); + private ArrayObjectAdapter mRowsAdapter; + private SearchRunnable mDelayedLoad; + + SearchProvider(Context context, boolean musicOnly) { + mRowsAdapter = new ArrayObjectAdapter(new ListRowPresenter()); + mDelayedLoad = new SearchRunnable(context, mRowsAdapter, musicOnly); + } + + @Override + public ObjectAdapter getResultsAdapter() { + return mRowsAdapter; + } + + @Override + public boolean onQueryTextChange(String query) { + search(query, true); + + return true; + } + + @Override + public boolean onQueryTextSubmit(String query) { + search(query, false); + + return true; + } + + /** + * Update search results + * + * @param query String to search for + * @param delayed When true the search is delayed by [SEARCH_DELAY_MS] milliseconds + */ + private void search(String query, boolean delayed) { + // Clear results when query is empty + if (Utils.isEmpty(query)) { + mRowsAdapter.clear(); + return; + } + + // Remove current delayed search (if any) + mHandler.removeCallbacks(mDelayedLoad); + + // Update search string + mDelayedLoad.setQueryString(query); + + // Schedule search depending on [delayed] + if (delayed) mHandler.postDelayed(mDelayedLoad, SEARCH_DELAY_MS); + else mHandler.post(mDelayedLoad); + } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/search/SearchRunnable.java b/app/src/main/java/org/jellyfin/androidtv/search/SearchRunnable.java index 18f099006..88b599362 100644 --- a/app/src/main/java/org/jellyfin/androidtv/search/SearchRunnable.java +++ b/app/src/main/java/org/jellyfin/androidtv/search/SearchRunnable.java @@ -1,22 +1,22 @@ package org.jellyfin.androidtv.search; -import android.app.Activity; -import androidx.leanback.widget.ArrayObjectAdapter; -import androidx.leanback.widget.HeaderItem; -import androidx.leanback.widget.ListRow; +import android.content.Context; import org.jellyfin.androidtv.R; import org.jellyfin.androidtv.itemhandling.ItemRowAdapter; import org.jellyfin.androidtv.presentation.CardPresenter; - import org.jellyfin.apiclient.model.search.SearchQuery; +import androidx.leanback.widget.ArrayObjectAdapter; +import androidx.leanback.widget.HeaderItem; +import androidx.leanback.widget.ListRow; + /** * Created by Eric on 4/8/2015. */ public class SearchRunnable implements Runnable { private String searchString; - private Activity activity; + private Context context; private ArrayObjectAdapter mRowsAdapter; private boolean musicOnly; @@ -24,8 +24,8 @@ public class SearchRunnable implements Runnable { searchString = value; } - public SearchRunnable(Activity activity, ArrayObjectAdapter adapter, boolean musicOnly) { - this.activity = activity; + public SearchRunnable(Context context, ArrayObjectAdapter adapter, boolean musicOnly) { + this.context = context; this.mRowsAdapter = adapter; this.musicOnly = musicOnly; } @@ -38,56 +38,56 @@ public class SearchRunnable implements Runnable { //Get search results by type SearchQuery people = getSearchQuery(new String[] {"Person","People"}); ItemRowAdapter peopleAdapter = new ItemRowAdapter(people, new CardPresenter(), mRowsAdapter); - ListRow peopleRow = new ListRow(new HeaderItem(activity.getString(R.string.lbl_people)), peopleAdapter); + ListRow peopleRow = new ListRow(new HeaderItem(context.getString(R.string.lbl_people)), peopleAdapter); peopleAdapter.setRow(peopleRow); peopleAdapter.Retrieve(); SearchQuery movies = getSearchQuery(new String[] {"Movie", "BoxSet"}); ItemRowAdapter movieAdapter = new ItemRowAdapter(movies, new CardPresenter(), mRowsAdapter); - ListRow movieRow = new ListRow(new HeaderItem(activity.getString(R.string.lbl_movies)), movieAdapter); + ListRow movieRow = new ListRow(new HeaderItem(context.getString(R.string.lbl_movies)), movieAdapter); movieAdapter.setRow(movieRow); movieAdapter.Retrieve(); SearchQuery tv = getSearchQuery(new String[] {"Series","Episode"}); ItemRowAdapter tvAdapter = new ItemRowAdapter(tv, new CardPresenter(), mRowsAdapter); - ListRow tvRow = new ListRow(new HeaderItem(activity.getString(R.string.lbl_tv)), tvAdapter); + ListRow tvRow = new ListRow(new HeaderItem(context.getString(R.string.lbl_tv)), tvAdapter); tvAdapter.setRow(tvRow); tvAdapter.Retrieve(); SearchQuery videos = getSearchQuery(new String[] {"Video"}); ItemRowAdapter videoAdapter = new ItemRowAdapter(videos, new CardPresenter(), mRowsAdapter); - ListRow videoRow = new ListRow(new HeaderItem(activity.getString(R.string.lbl_videos)), videoAdapter); + ListRow videoRow = new ListRow(new HeaderItem(context.getString(R.string.lbl_videos)), videoAdapter); videoAdapter.setRow(videoRow); videoAdapter.Retrieve(); SearchQuery recordings = getSearchQuery(new String[] {"Recording"}); ItemRowAdapter recordingAdapter = new ItemRowAdapter(recordings, new CardPresenter(), mRowsAdapter); - ListRow recordingRow = new ListRow(new HeaderItem(activity.getString(R.string.lbl_recordings)), recordingAdapter); + ListRow recordingRow = new ListRow(new HeaderItem(context.getString(R.string.lbl_recordings)), recordingAdapter); recordingAdapter.setRow(recordingRow); recordingAdapter.Retrieve(); SearchQuery programs = getSearchQuery(new String[] {"Program"}); ItemRowAdapter programAdapter = new ItemRowAdapter(programs, new CardPresenter(), mRowsAdapter); - ListRow programRow = new ListRow(new HeaderItem(activity.getString(R.string.lbl_programs)), programAdapter); + ListRow programRow = new ListRow(new HeaderItem(context.getString(R.string.lbl_programs)), programAdapter); programAdapter.setRow(programRow); programAdapter.Retrieve(); } SearchQuery artists = getSearchQuery(new String[] {"MusicArtist"}); ItemRowAdapter artistAdapter = new ItemRowAdapter(artists, new CardPresenter(), mRowsAdapter); - ListRow artistRow = new ListRow(new HeaderItem(activity.getString(R.string.lbl_artists)), artistAdapter); + ListRow artistRow = new ListRow(new HeaderItem(context.getString(R.string.lbl_artists)), artistAdapter); artistAdapter.setRow(artistRow); artistAdapter.Retrieve(); SearchQuery albums = getSearchQuery(new String[] {"MusicAlbum"}); ItemRowAdapter albumAdapter = new ItemRowAdapter(albums, new CardPresenter(), mRowsAdapter); - ListRow albumRow = new ListRow(new HeaderItem(activity.getString(R.string.lbl_albums)), albumAdapter); + ListRow albumRow = new ListRow(new HeaderItem(context.getString(R.string.lbl_albums)), albumAdapter); albumAdapter.setRow(albumRow); albumAdapter.Retrieve(); SearchQuery songs = getSearchQuery(new String[] {"Audio"}); ItemRowAdapter songAdapter = new ItemRowAdapter(songs, new CardPresenter(), mRowsAdapter); - ListRow songRow = new ListRow(new HeaderItem(activity.getString(R.string.lbl_songs)), songAdapter); + ListRow songRow = new ListRow(new HeaderItem(context.getString(R.string.lbl_songs)), songAdapter); songAdapter.setRow(songRow); songAdapter.Retrieve(); diff --git a/app/src/main/java/org/jellyfin/androidtv/search/TextSearchFragment.java b/app/src/main/java/org/jellyfin/androidtv/search/TextSearchFragment.java new file mode 100644 index 000000000..4fc5e7da4 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/search/TextSearchFragment.java @@ -0,0 +1,84 @@ +package org.jellyfin.androidtv.search; + +import android.os.Bundle; +import android.text.Editable; +import android.text.TextWatcher; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.inputmethod.EditorInfo; +import android.widget.EditText; +import android.widget.TextView; + +import org.jellyfin.androidtv.R; +import org.jellyfin.androidtv.itemhandling.BaseRowItem; +import org.jellyfin.androidtv.itemhandling.ItemLauncher; +import org.jellyfin.androidtv.itemhandling.ItemRowAdapter; + +import androidx.fragment.app.Fragment; +import androidx.leanback.app.RowsSupportFragment; +import androidx.leanback.widget.ListRow; + +public class TextSearchFragment extends Fragment implements TextWatcher, TextView.OnEditorActionListener { + private SearchProvider searchProvider; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + searchProvider = new SearchProvider(getContext(), getActivity().getIntent().getBooleanExtra("MusicOnly", false)); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_search_text, container, false); + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + // Add event listeners + EditText searchBar = getActivity().findViewById(R.id.search_bar); + searchBar.addTextChangedListener(this); + searchBar.setOnEditorActionListener(this); + + // Set up result fragment + RowsSupportFragment rowsSupportFragment = (RowsSupportFragment) getChildFragmentManager().findFragmentById(R.id.results_frame); + rowsSupportFragment.setAdapter(searchProvider.getResultsAdapter()); + + // Create click listener + rowsSupportFragment.setOnItemViewClickedListener((itemViewHolder, item, rowViewHolder, row) -> { + if (!(item instanceof BaseRowItem)) return; + + ItemLauncher.launch((BaseRowItem) item, (ItemRowAdapter) ((ListRow) row).getAdapter(), ((BaseRowItem) item).getIndex(), getActivity()); + }); + } + + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + // No implementation + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + // No implementation + } + + @Override + public void afterTextChanged(Editable s) { + searchProvider.onQueryTextChange(s.toString()); + } + + @Override + public boolean onEditorAction(TextView v, int actionId, KeyEvent event) { + // Detect keyboard "submit" actions + if (actionId == EditorInfo.IME_ACTION_SEARCH) + searchProvider.onQueryTextSubmit(v.getText().toString()); + + // Return "false" to automatically close keyboard + return false; + } +} + diff --git a/app/src/main/java/org/jellyfin/androidtv/settings/SettingsFragment.java b/app/src/main/java/org/jellyfin/androidtv/settings/SettingsFragment.java index 763e278f2..aa2f4dbb5 100644 --- a/app/src/main/java/org/jellyfin/androidtv/settings/SettingsFragment.java +++ b/app/src/main/java/org/jellyfin/androidtv/settings/SettingsFragment.java @@ -113,7 +113,7 @@ public class SettingsFragment extends PreferenceFragment implements SharedPrefer if (!TvApp.getApplication().getConfiguredAutoCredentials().getUserDto().getId().equals(TvApp.getApplication().getCurrentUser().getId())) { listPreference.setEnabled(false); pwPrompt.setEnabled(false); - extra = getActivity().getString(R.string.lbl_paren_login_as)+TvApp.getApplication().getConfiguredAutoCredentials().getUserDto().getName()+getActivity().getString(R.string.lbl_to_change_paren); + extra = getActivity().getString(R.string.lbl_paren_login_as) + TvApp.getApplication().getConfiguredAutoCredentials().getUserDto().getName() + getActivity().getString(R.string.lbl_to_change_paren); } listPreference.setSummary(getActivity().getString(R.string.lbl_login_as) + TvApp.getApplication().getConfiguredAutoCredentials().getUserDto().getName() + extra); } else { @@ -121,6 +121,18 @@ public class SettingsFragment extends PreferenceFragment implements SharedPrefer pwPrompt.setEnabled(false); } } else { + if (listPreference.getKey().equals("pref_video_player")) { + boolean isExternal = listPreference.getValue().equals("external"); + + // enable/disable other related items + Preference direct = findPreference("pref_send_path_external"); + if (direct != null) direct.setEnabled(isExternal); + for (String key : extPlayerVideoDep) { + Preference pref = findPreference(key); + if (pref != null) pref.setEnabled(!isExternal); + } + } + listPreference.setSummary(listPreference.getEntry()); } } @@ -131,14 +143,6 @@ public class SettingsFragment extends PreferenceFragment implements SharedPrefer // enable other live tv direct only options Preference live = findPreference("pref_enable_vlc_livetv"); if (live != null) live.setEnabled(cb.isChecked()); - } else if (cb.getKey().equals("pref_video_use_external")) { - // enable/disable other related items - Preference direct = findPreference("pref_send_path_external"); - if (direct != null) direct.setEnabled(cb.isChecked()); - for (String key: extPlayerVideoDep) { - Preference pref = findPreference(key); - if (pref != null) pref.setEnabled(!cb.isChecked()); - } } else if (cb.getKey().equals("pref_live_tv_use_external")) { // enable/disable other related items for (String key: extPlayerLiveTvDep) { diff --git a/app/src/main/java/org/jellyfin/androidtv/startup/StartupActivity.java b/app/src/main/java/org/jellyfin/androidtv/startup/StartupActivity.java index f38dc1f6f..cbe5553cb 100644 --- a/app/src/main/java/org/jellyfin/androidtv/startup/StartupActivity.java +++ b/app/src/main/java/org/jellyfin/androidtv/startup/StartupActivity.java @@ -70,6 +70,12 @@ public class StartupActivity extends FragmentActivity { application.getPrefs().edit().putBoolean("pref_enable_info_panel", false).apply(); application.getSystemPrefs().edit().putString("sys_pref_config_version", "4").apply(); } + if (Integer.parseInt(application.getConfigVersion()) < 5) { + boolean useExternal = application.getPrefs().getBoolean("pref_video_use_external", false); + + application.getPrefs().edit().putString("pref_video_player", useExternal ? "external" : "auto").apply(); + application.getSystemPrefs().edit().putString("sys_pref_config_version", "5").apply(); + } //Ensure we have prefs PreferenceManager.setDefaultValues(this, R.xml.preferences, false); diff --git a/app/src/main/java/org/jellyfin/androidtv/util/apiclient/ApiClientExtensions.kt b/app/src/main/java/org/jellyfin/androidtv/util/apiclient/ApiClientExtensions.kt new file mode 100644 index 000000000..e7362efbf --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/util/apiclient/ApiClientExtensions.kt @@ -0,0 +1,18 @@ +package org.jellyfin.androidtv.util.apiclient + +import org.jellyfin.apiclient.interaction.ApiClient +import org.jellyfin.apiclient.interaction.Response +import org.jellyfin.apiclient.model.querying.ItemsResult +import org.jellyfin.apiclient.model.querying.NextUpQuery +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +/** + * Adds a coroutine capable version of the "getNextUpEpisodes" function + */ +suspend fun ApiClient.getNextUpEpisodes(query: NextUpQuery): ItemsResult? = suspendCoroutine { continuation -> + GetNextUpEpisodesAsync(query, object : Response() { + override fun onResponse(response: ItemsResult?) = continuation.resume(response!!) + override fun onError(exception: Exception?) = continuation.resume(null) + }) +} diff --git a/app/src/main/res/layout/activity_search.xml b/app/src/main/res/layout/activity_search.xml deleted file mode 100644 index 46b4af9e7..000000000 --- a/app/src/main/res/layout/activity_search.xml +++ /dev/null @@ -1,11 +0,0 @@ - - diff --git a/app/src/main/res/layout/activity_search_no_speech.xml b/app/src/main/res/layout/activity_search_no_speech.xml deleted file mode 100644 index 42ea1fd4d..000000000 --- a/app/src/main/res/layout/activity_search_no_speech.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - diff --git a/app/src/main/res/layout/fragment_empty_frame.xml b/app/src/main/res/layout/fragment_empty_frame.xml deleted file mode 100644 index 2b9f7a2e6..000000000 --- a/app/src/main/res/layout/fragment_empty_frame.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_search_text.xml b/app/src/main/res/layout/fragment_search_text.xml new file mode 100644 index 000000000..2a7efb93c --- /dev/null +++ b/app/src/main/res/layout/fragment_search_text.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/vlc_player_interface.xml b/app/src/main/res/layout/vlc_player_interface.xml index f3c413323..7e6c2d3ed 100644 --- a/app/src/main/res/layout/vlc_player_interface.xml +++ b/app/src/main/res/layout/vlc_player_interface.xml @@ -3,6 +3,7 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" + xmlns:app="http://schemas.android.com/apk/res-auto" android:background="@color/black"> - - diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 1466a7bdb..f4b136687 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -274,8 +274,6 @@ Přímý průchod Vyžaduje vhodnou aplikaci pro přehrávání live streamů Použít externí přehrávač pro Live TV - Vyžaduje vhodnou aplikaci pro přehrávání videa - Použít externí přehrávač pro videa Název skladby Název alba Další epizoda diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index f04ed20d5..62eeda10b 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -259,8 +259,6 @@ Direkten Pfad übergeben Benötigt eine Anwendung, welche Livestreams wiedergeben kann Externen Player für Live TV verwenden - Benötigt eine Anwendung, welche die Videodateien wiedergeben kann - Externen Player für Videos verwenden Songtitel Album Name Nächste Episode diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index f9095309f..369f0b62a 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -19,8 +19,6 @@ Pass Direct Path Requires a capable app to play live streams Use External Player for Live TV - Requires a capable app to play video types - Use External Player for Videos Song Title Album name Next Episode diff --git a/app/src/main/res/values-es-rMX/strings.xml b/app/src/main/res/values-es-rMX/strings.xml index 131f0d0b2..e3faf592d 100644 --- a/app/src/main/res/values-es-rMX/strings.xml +++ b/app/src/main/res/values-es-rMX/strings.xml @@ -264,8 +264,6 @@ Pasar ruta directa Requiere una aplicación capaz para reproducir transmisiones en vivo Usar reproductor externo para TV en vivo - Requiere una aplicación capaz para reproducir tipos de vídeo - Usar un reproductor externo para vídeos Titulo de Canción Nombre de álbum Siguiente Episodio diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index d3780bc11..d027eab32 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -189,7 +189,6 @@ Pasar ruta directa Requiere una aplicación capaz para reproducir transmisiones en vivo Usar reproductor externo para la televisión en vivo - Usar un reproductor externo para vídeos Título de la canción Nombre del álbum Próximo episodio diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index d54b275aa..890259946 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -292,8 +292,6 @@ Épisode suivant Nom de l\'album titre de la chanson - Utiliser un lecteur externe pour les vidéos - Nécessite une application capable de lire les vidéos Utiliser le chemin directe Utiliser le chemin du fichier de l\'url de streaming de %s diff --git a/app/src/main/res/values-kk/strings.xml b/app/src/main/res/values-kk/strings.xml index ffb38d4e7..c8de08483 100644 --- a/app/src/main/res/values-kk/strings.xml +++ b/app/src/main/res/values-kk/strings.xml @@ -288,7 +288,6 @@ Eń keıingi qaralǵan efırlik arnaǵa dereý ótý Sýret ólshemi Tikeleı aǵymdardy oınatý úshin jaramdy qoldanbany qajet etedi - Beıne túrlerin oınatý úshin jaramdy qoldanbany qajet etedi Qoldaý kórsetilmeıtin server nusqasy. Server nusqasy %1$s nemese odan keıingi bolýy qajet. Sýret túri Ádepki kórinis @@ -312,7 +311,6 @@ Usynystar Tikeleı shyǵarý Efır úshin syrtqy oınatqyshty paıdalaný - Beıneler úshin syrtqy oınatqyshty paıdalaný Áýen ataýy Kelesi bólimge Aldyńǵy bólimge diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 8079388d7..3507cec77 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -212,8 +212,6 @@ 다음 에피소드 앨범 이름 노래 제목 - 비디오에 외부 재생기 사용 - 비디오 타입을 재생하려면 호환되는 앱이 필요합니다 실시간 TV에 외부 재생기 사용 실시간 스트림을 재생하려면 호환되는 앱이 필요합니다 \ No newline at end of file diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index 6caac392d..596c2f354 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -228,8 +228,6 @@ Send filbanen direkte Krever en støttet app for å spille av direktestrømmer Bruk eksterm spiller for direkte-TV - Krever en støttet app for å spille av videotyper - Bruk ekstern spiller for videoer Låtnavn Albumnavn Neste episode diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 1c527f34a..ecfb745ca 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -267,7 +267,6 @@ Fijne feestdagen! Suggesties Gebruik externe speler voor Live TV - Gebruik externe speler voor video\'s Album naam Volgende Aflevering Vorige Aflevering @@ -301,7 +300,6 @@ Fijne Halloween! Griezelige suggesties Heeft een bruikbare app nodig om live streams af te spelen - Heeft een bruikbare app nodig om video types af te spelen Titel van het nummer Meer als dit Hervat vanaf %s diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 43a1c4718..c40850a7b 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -250,7 +250,6 @@ Enviar o local do sistema de arquivos invés da URL de streaming Requer um app compatível de reproduzir streams ao vivo Usar Reprodutor Externo para TV ao Vivo - Requer um app compatível para reproduzir os tipos de vídeo Título da Música Nome do álbum Próximo Episódio @@ -311,7 +310,6 @@ Feliz Halloween! Sugestões Assustadoras Sugestões - Usar Player Externo para Vídeos Séries Gravadas Ocorreu um erro ao fazer login com suas credenciais salvas. Versão do servidor não suportada. Versão do servidor deve ser %1$s ou posterior. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index b7f944bb9..ff060a7a7 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -251,7 +251,6 @@ Транслировать DTS аудио Избранное Использовать для эфира внешний проигрыватель - Использовать для видео внешний проигрыватель Название композиции Название альбома Следующий эпизод @@ -290,7 +289,6 @@ Выводить путь в файловой системе вместо URL потока Прямой вывод Требуется совместимое приложение для воспроизведения эфирных трансляций - Требуется совместимое приложение для воспроизведения видеотипов Ещё подобное этому Откат для возобновления Последнее воспроизведение diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 78196575e..35bd0826b 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -151,8 +151,6 @@ 通过直接路径 需要一个有能力的应用程序播放直播流 使用外部播放器播放电视直播 - 需要一个有能力的应用程序来播放视频类型 - 使用外部播放器播放视频 歌曲标题 专辑名 下一个事件 diff --git a/app/src/main/res/values/array.xml b/app/src/main/res/values/array.xml index ab8141c0f..a5897a09d 100644 --- a/app/src/main/res/values/array.xml +++ b/app/src/main/res/values/array.xml @@ -101,4 +101,16 @@ 0 1 + + @string/pref_video_player_auto + @string/pref_video_player_exoplayer + @string/pref_video_player_vlc + @string/pref_video_player_external + + + auto + exoplayer + vlc + external + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 99874797c..8d6c2c725 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -305,11 +305,9 @@ Next Episode Album name Song Title - Use External Player for Videos - Requires a capable app to play video types Use External Player for Live TV Requires a capable app to play live streams - Pass Direct Path + Pass Direct Path for external player Pass the file system path instead of streaming URL Crash Reporting Send crash reports to developers @@ -345,4 +343,8 @@ Image Type Image Size My Media + Automatically choose + ExoPlayer + libVLC + External app \ No newline at end of file diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index d769d5732..c7b386245 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -75,6 +75,11 @@ android:entries="@array/pref_preroll_options" android:entryValues="@array/pref_preroll_values" /> + -