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