diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index def0a18..fc60a47 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -58,6 +58,16 @@ android:configChanges="orientation|screenSize" android:label="@string/app_name" android:windowSoftInputMode="stateAlwaysHidden" /> + + asyncTask; + private List playlists; + private RelativeLayout mainLoader; + private FloatingActionButton add_new; + private PlaylistAdapter playlistAdapter; + private RelativeLayout textviewNoAction; + private HashMap privacyToSend; + private HashMap channelToSend; + private Spinner set_upload_channel; + private Spinner set_upload_privacy; + private HashMap channels; + + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setContentView(R.layout.activity_all_playlist); + + if (getSupportActionBar() != null) + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + setTitle(R.string.playlists); + + playlists = new ArrayList<>(); + + + ListView lv_playlist = findViewById(R.id.lv_playlist); + textviewNoAction = findViewById(R.id.no_action); + mainLoader = findViewById(R.id.loader); + RelativeLayout nextElementLoader = findViewById(R.id.loading_next_items); + mainLoader.setVisibility(View.VISIBLE); + nextElementLoader.setVisibility(View.GONE); + playlists = new ArrayList<>(); + playlistAdapter = new PlaylistAdapter(AllPlaylistsActivity.this, playlists, textviewNoAction); + lv_playlist.setAdapter(playlistAdapter); + asyncTask = new ManagePlaylistsAsyncTask(AllPlaylistsActivity.this, ManagePlaylistsAsyncTask.action.GET_PLAYLIST, null, null, null, AllPlaylistsActivity.this).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + add_new = findViewById(R.id.add_new); + + + LinkedHashMap privaciesInit = new LinkedHashMap<>(peertubeInformation.getPrivacies()); + Map.Entry entryInt = privaciesInit.entrySet().iterator().next(); + privacyToSend = new HashMap<>(); + privacyToSend.put(entryInt.getKey(), entryInt.getValue()); + + + if (add_new != null) { + add_new.setOnClickListener(view -> { + final SharedPreferences sharedpreferences = getSharedPreferences(Helper.APP_PREFS, Context.MODE_PRIVATE); + AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(AllPlaylistsActivity.this); + LayoutInflater inflater1 = getLayoutInflater(); + View dialogView = inflater1.inflate(R.layout.add_playlist, new LinearLayout(AllPlaylistsActivity.this), false); + dialogBuilder.setView(dialogView); + EditText display_name = dialogView.findViewById(R.id.display_name); + EditText description = dialogView.findViewById(R.id.description); + set_upload_channel = dialogView.findViewById(R.id.set_upload_channel); + set_upload_privacy = dialogView.findViewById(R.id.set_upload_privacy); + + + new RetrievePeertubeChannelsAsyncTask(AllPlaylistsActivity.this, AllPlaylistsActivity.this).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + + display_name.setFilters(new InputFilter[]{new InputFilter.LengthFilter(120)}); + description.setFilters(new InputFilter[]{new InputFilter.LengthFilter(1000)}); + + dialogBuilder.setPositiveButton(R.string.validate, (dialog, id) -> { + + if (display_name.getText() != null && display_name.getText().toString().trim().length() > 0) { + + Playlist playlist = new Playlist(); + playlist.setDisplayName(display_name.getText().toString().trim()); + if (description.getText() != null && description.getText().toString().trim().length() > 0) { + playlist.setDescription(description.getText().toString().trim()); + } + String idChannel = null; + if (channelToSend != null) { + Map.Entry channelM = channelToSend.entrySet().iterator().next(); + idChannel = channelM.getValue(); + if (idChannel.length() > 0) + playlist.setVideoChannelId(idChannel); + } + Map.Entry privacyM = privacyToSend.entrySet().iterator().next(); + String label = privacyM.getValue(); + String idPrivacy = String.valueOf(privacyM.getKey()); + if (label.equals("Public") && (playlist.getVideoChannelId() == null || playlist.getVideoChannelId().equals(""))) { + Toasty.error(AllPlaylistsActivity.this, getString(R.string.error_channel_mandatory), Toast.LENGTH_LONG).show(); + } else { + if (privacyToSend != null) { + playlist.setPrivacy(privacyToSend); + } + //new ManagePlaylistsAsyncTask(context, ManagePlaylistsAsyncTask.action.CREATE_PLAYLIST, playlist, null, null, DisplayPlaylistsFragment.this).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + UploadNotificationConfig uploadConfig = new UploadNotificationConfig(); + uploadConfig.getCompleted().autoClear = true; + try { + String token = sharedpreferences.getString(Helper.PREF_KEY_OAUTH_TOKEN, null); + new MultipartUploadRequest(AllPlaylistsActivity.this, "https://" + Helper.getLiveInstance(AllPlaylistsActivity.this) + "/api/v1/video-playlists/") + //.addFileToUpload(uri.toString().replace("file://",""), "videofile") + .addHeader("Authorization", "Bearer " + token) + .setNotificationConfig(uploadConfig) + // .addParameter("name", filename) + .addParameter("videoChannelId", idChannel) + .addParameter("privacy", idPrivacy) + .addParameter("displayName", playlist.getDisplayName()) + .addParameter("description", playlist.getDescription()) + .setMaxRetries(1) + .setDelegate(new UploadStatusDelegate() { + @Override + public void onProgress(Context context, UploadInfo uploadInfo) { + // your code here + } + + @Override + public void onError(Context context, UploadInfo uploadInfo, ServerResponse serverResponse, + Exception exception) { + // your code here + exception.printStackTrace(); + } + + @Override + public void onCompleted(Context context, UploadInfo uploadInfo, ServerResponse serverResponse) { + DisplayPlaylistsFragment displayPlaylistsFragment; + displayPlaylistsFragment = (DisplayPlaylistsFragment) getSupportFragmentManager().findFragmentByTag("PLAYLISTS"); + final FragmentTransaction ft = getSupportFragmentManager().beginTransaction(); + if (displayPlaylistsFragment != null) { + ft.detach(displayPlaylistsFragment); + ft.attach(displayPlaylistsFragment); + ft.commit(); + } + } + + @Override + public void onCancelled(Context context, UploadInfo uploadInfo) { + // your code here + } + }) + .startUpload(); + } catch (MalformedURLException e) { + e.printStackTrace(); + } + + dialog.dismiss(); + add_new.setEnabled(false); + } + } else { + Toasty.error(AllPlaylistsActivity.this, getString(R.string.error_display_name), Toast.LENGTH_LONG).show(); + } + + }); + dialogBuilder.setNegativeButton(R.string.cancel, (dialog, id) -> dialog.dismiss()); + + + AlertDialog alertDialog = dialogBuilder.create(); + alertDialog.setTitle(getString(R.string.action_playlist_create)); + alertDialog.setOnDismissListener(dialogInterface -> { + //Hide keyboard + InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); + assert imm != null; + imm.hideSoftInputFromWindow(display_name.getWindowToken(), 0); + }); + if (alertDialog.getWindow() != null) + alertDialog.getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE); + alertDialog.show(); + }); + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (asyncTask != null && !asyncTask.isCancelled()) { + asyncTask.cancel(true); + } + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == android.R.id.home) { + finish(); + return true; + } + return super.onOptionsItemSelected(item); + } + + + @Override + public void onActionDone(ManagePlaylistsAsyncTask.action actionType, APIResponse apiResponse, int statusCode) { + mainLoader.setVisibility(View.GONE); + add_new.setEnabled(true); + if (apiResponse.getError() != null) { + Toasty.error(AllPlaylistsActivity.this, apiResponse.getError().getError(), Toast.LENGTH_LONG).show(); + return; + } + + if (actionType == ManagePlaylistsAsyncTask.action.GET_PLAYLIST) { + if (apiResponse.getPlaylists() != null && apiResponse.getPlaylists().size() > 0) { + this.playlists.addAll(apiResponse.getPlaylists()); + playlistAdapter.notifyDataSetChanged(); + textviewNoAction.setVisibility(View.GONE); + } else { + textviewNoAction.setVisibility(View.VISIBLE); + } + } else if (actionType == ManagePlaylistsAsyncTask.action.CREATE_PLAYLIST) { + if (apiResponse.getPlaylists() != null && apiResponse.getPlaylists().size() > 0) { + Intent intent = new Intent(AllPlaylistsActivity.this, PlaylistsActivity.class); + Bundle b = new Bundle(); + b.putParcelable("playlist", apiResponse.getPlaylists().get(0)); + intent.putExtras(b); + startActivity(intent); + this.playlists.add(0, apiResponse.getPlaylists().get(0)); + playlistAdapter.notifyDataSetChanged(); + textviewNoAction.setVisibility(View.GONE); + } else { + Toasty.error(AllPlaylistsActivity.this, apiResponse.getError().getError(), Toast.LENGTH_LONG).show(); + } + } else if (actionType == ManagePlaylistsAsyncTask.action.DELETE_PLAYLIST) { + if (this.playlists.size() == 0) + textviewNoAction.setVisibility(View.VISIBLE); + } + } + + + @Override + public void onRetrievePeertube(APIResponse apiResponse) { + + } + + @Override + public void onRetrievePeertubeComments(APIResponse apiResponse) { + + } + + @Override + public void onRetrievePeertubeChannels(APIResponse apiResponse) { + if (apiResponse.getError() != null || apiResponse.getAccounts() == null || apiResponse.getAccounts().size() == 0) { + if (apiResponse.getError() != null && apiResponse.getError().getError() != null) + Toasty.error(AllPlaylistsActivity.this, apiResponse.getError().getError(), Toast.LENGTH_LONG).show(); + else + Toasty.error(AllPlaylistsActivity.this, getString(R.string.toast_error), Toast.LENGTH_LONG).show(); + return; + } + + //Populate channels + List accounts = apiResponse.getAccounts(); + String[] channelName = new String[accounts.size() + 1]; + String[] channelId = new String[accounts.size() + 1]; + int i = 1; + channelName[0] = ""; + channelId[0] = ""; + channels = new HashMap<>(); + for (Account account : accounts) { + channels.put(account.getUsername(), account.getId()); + channelName[i] = account.getUsername(); + channelId[i] = account.getId(); + i++; + } + + channelToSend = new HashMap<>(); + channelToSend.put(channelName[0], channelId[0]); + ArrayAdapter adapterChannel = new ArrayAdapter<>(AllPlaylistsActivity.this, + android.R.layout.simple_spinner_dropdown_item, channelName); + set_upload_channel.setAdapter(adapterChannel); + + LinkedHashMap translations = null; + if (peertubeInformation.getTranslations() != null) + translations = new LinkedHashMap<>(peertubeInformation.getTranslations()); + + LinkedHashMap privaciesInit = new LinkedHashMap<>(peertubeInformation.getPlaylistPrivacies()); + Map.Entry entryInt = privaciesInit.entrySet().iterator().next(); + privacyToSend = new HashMap<>(); + privacyToSend.put(entryInt.getKey(), entryInt.getValue()); + LinkedHashMap privacies = new LinkedHashMap<>(peertubeInformation.getPlaylistPrivacies()); + //Populate privacies + String[] privaciesA = new String[privacies.size()]; + Iterator> it = privacies.entrySet().iterator(); + i = 0; + while (it.hasNext()) { + Map.Entry pair = it.next(); + if (translations == null || translations.size() == 0 || !translations.containsKey(pair.getValue())) + privaciesA[i] = pair.getValue(); + else + privaciesA[i] = translations.get(pair.getValue()); + it.remove(); + i++; + } + + ArrayAdapter adapterPrivacies = new ArrayAdapter<>(AllPlaylistsActivity.this, + android.R.layout.simple_spinner_dropdown_item, privaciesA); + set_upload_privacy.setAdapter(adapterPrivacies); + + //Manage privacies + set_upload_privacy.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + LinkedHashMap privaciesCheck = new LinkedHashMap<>(peertubeInformation.getPrivacies()); + Iterator> it = privaciesCheck.entrySet().iterator(); + int i = 0; + while (it.hasNext()) { + Map.Entry pair = it.next(); + if (i == position) { + privacyToSend = new HashMap<>(); + privacyToSend.put(pair.getKey(), pair.getValue()); + break; + } + it.remove(); + i++; + } + } + + @Override + public void onNothingSelected(AdapterView parent) { + + } + }); + //Manage languages + set_upload_channel.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + LinkedHashMap channelsCheck = new LinkedHashMap<>(channels); + Iterator> it = channelsCheck.entrySet().iterator(); + int i = 0; + while (it.hasNext()) { + Map.Entry pair = it.next(); + if (i == position) { + channelToSend = new HashMap<>(); + channelToSend.put(pair.getKey(), pair.getValue()); + break; + } + it.remove(); + i++; + } + } + + @Override + public void onNothingSelected(AdapterView parent) { + + } + }); + } +} diff --git a/app/src/main/java/app/fedilab/fedilabtube/MainActivity.java b/app/src/main/java/app/fedilab/fedilabtube/MainActivity.java index a748c0e..2a65301 100644 --- a/app/src/main/java/app/fedilab/fedilabtube/MainActivity.java +++ b/app/src/main/java/app/fedilab/fedilabtube/MainActivity.java @@ -77,12 +77,15 @@ public class MainActivity extends AppCompatActivity { MenuItem uploadItem = menu.findItem(R.id.action_upload); MenuItem myVideosItem = menu.findItem(R.id.action_myvideos); + MenuItem playslistItem = menu.findItem(R.id.action_playlist); if (Helper.isLoggedIn(MainActivity.this)) { uploadItem.setVisible(true); myVideosItem.setVisible(true); + playslistItem.setVisible(true); } else { uploadItem.setVisible(false); myVideosItem.setVisible(false); + playslistItem.setVisible(false); } return true; } @@ -104,6 +107,10 @@ public class MainActivity extends AppCompatActivity { Intent intent = new Intent(MainActivity.this, MyVideosActivity.class); startActivity(intent); return true; + } else if (item.getItemId() == R.id.action_playlist) { + Intent intent = new Intent(MainActivity.this, AllPlaylistsActivity.class); + startActivity(intent); + return true; } return super.onOptionsItemSelected(item); } diff --git a/app/src/main/java/app/fedilab/fedilabtube/PeertubeActivity.java b/app/src/main/java/app/fedilab/fedilabtube/PeertubeActivity.java index fb11cb8..56908b7 100644 --- a/app/src/main/java/app/fedilab/fedilabtube/PeertubeActivity.java +++ b/app/src/main/java/app/fedilab/fedilabtube/PeertubeActivity.java @@ -228,7 +228,7 @@ public class PeertubeActivity extends AppCompatActivity implements OnRetrievePee initFullscreenDialog(); initFullscreenButton(); } - if( Helper.isLoggedIn(PeertubeActivity.this)) { + if (Helper.isLoggedIn(PeertubeActivity.this)) { new ManagePlaylistsAsyncTask(PeertubeActivity.this, GET_PLAYLIST, null, null, null, PeertubeActivity.this).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); } diff --git a/app/src/main/java/app/fedilab/fedilabtube/PlaylistsActivity.java b/app/src/main/java/app/fedilab/fedilabtube/PlaylistsActivity.java new file mode 100644 index 0000000..070c7eb --- /dev/null +++ b/app/src/main/java/app/fedilab/fedilabtube/PlaylistsActivity.java @@ -0,0 +1,173 @@ +package app.fedilab.fedilabtube; + + +import android.os.AsyncTask; +import android.os.Bundle; +import android.view.MenuItem; +import android.view.View; +import android.widget.RelativeLayout; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; + +import java.util.ArrayList; +import java.util.List; + +import app.fedilab.fedilabtube.asynctasks.ManagePlaylistsAsyncTask; +import app.fedilab.fedilabtube.client.APIResponse; +import app.fedilab.fedilabtube.client.entities.Peertube; +import app.fedilab.fedilabtube.client.entities.Playlist; +import app.fedilab.fedilabtube.drawer.PeertubeAdapter; +import app.fedilab.fedilabtube.interfaces.OnPlaylistActionInterface; +import es.dmoral.toasty.Toasty; + +import static app.fedilab.fedilabtube.asynctasks.ManagePlaylistsAsyncTask.action.GET_LIST_VIDEOS; + + +public class PlaylistsActivity extends AppCompatActivity implements OnPlaylistActionInterface { + + + LinearLayoutManager mLayoutManager; + private RelativeLayout mainLoader, nextElementLoader, textviewNoAction; + private SwipeRefreshLayout swipeRefreshLayout; + private boolean swiped; + private List peertubes; + private String max_id; + private Playlist playlist; + private boolean firstLoad; + private boolean flag_loading; + private PeertubeAdapter peertubeAdapter; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + if (getSupportActionBar() != null) + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + + + setContentView(R.layout.activity_playlists); + peertubes = new ArrayList<>(); + + RecyclerView lv_playlist = findViewById(R.id.lv_playlist); + mainLoader = findViewById(R.id.loader); + nextElementLoader = findViewById(R.id.loading_next_status); + textviewNoAction = findViewById(R.id.no_action); + mainLoader.setVisibility(View.VISIBLE); + swipeRefreshLayout = findViewById(R.id.swipeContainer); + + + max_id = null; + flag_loading = true; + firstLoad = true; + swiped = false; + + + mainLoader.setVisibility(View.VISIBLE); + nextElementLoader.setVisibility(View.GONE); + + peertubeAdapter = new PeertubeAdapter(this.peertubes); + + lv_playlist.setAdapter(peertubeAdapter); + mLayoutManager = new LinearLayoutManager(PlaylistsActivity.this); + lv_playlist.setLayoutManager(mLayoutManager); + + Bundle b = getIntent().getExtras(); + if (b != null) { + playlist = b.getParcelable("playlist"); + } else { + Toasty.error(PlaylistsActivity.this, getString(R.string.toast_error_search), Toast.LENGTH_LONG).show(); + return; + } + if (getSupportActionBar() != null) + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + + setTitle(playlist.getDisplayName()); + + + lv_playlist.addOnScrollListener(new RecyclerView.OnScrollListener() { + public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { + int firstVisibleItem = mLayoutManager.findFirstVisibleItemPosition(); + if (dy > 0) { + int visibleItemCount = mLayoutManager.getChildCount(); + int totalItemCount = mLayoutManager.getItemCount(); + if (firstVisibleItem + visibleItemCount == totalItemCount) { + if (!flag_loading) { + flag_loading = true; + new ManagePlaylistsAsyncTask(PlaylistsActivity.this, GET_LIST_VIDEOS, playlist, null, max_id, PlaylistsActivity.this).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + nextElementLoader.setVisibility(View.VISIBLE); + } + } else { + nextElementLoader.setVisibility(View.GONE); + } + } + } + }); + + + swipeRefreshLayout.setOnRefreshListener(() -> { + max_id = null; + firstLoad = true; + flag_loading = true; + swiped = true; + new ManagePlaylistsAsyncTask(PlaylistsActivity.this, GET_LIST_VIDEOS, playlist, null, null, PlaylistsActivity.this).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + }); + + new ManagePlaylistsAsyncTask(PlaylistsActivity.this, GET_LIST_VIDEOS, playlist, null, null, PlaylistsActivity.this).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == android.R.id.home) { + finish(); + return true; + } + return super.onOptionsItemSelected(item); + } + + + @Override + public void onActionDone(ManagePlaylistsAsyncTask.action actionType, APIResponse apiResponse, int statusCode) { + mainLoader.setVisibility(View.GONE); + nextElementLoader.setVisibility(View.GONE); + //Discards 404 - error which can often happen due to toots which have been deleted + if (apiResponse.getError() != null) { + if (!apiResponse.getError().getError().startsWith("404 -") && !apiResponse.getError().getError().startsWith("501 -")) + Toasty.error(PlaylistsActivity.this, apiResponse.getError().getError(), Toast.LENGTH_LONG).show(); + swipeRefreshLayout.setRefreshing(false); + swiped = false; + flag_loading = false; + return; + } + if (actionType == GET_LIST_VIDEOS) { + + int previousPosition = this.peertubes.size(); + List videos = apiResponse.getPeertubes(); + max_id = apiResponse.getMax_id(); + flag_loading = (max_id == null); + if (!swiped && firstLoad && (videos == null || videos.size() == 0)) + textviewNoAction.setVisibility(View.VISIBLE); + else + textviewNoAction.setVisibility(View.GONE); + + if (swiped) { + if (previousPosition > 0) { + this.peertubes.subList(0, previousPosition).clear(); + peertubeAdapter.notifyItemRangeRemoved(0, previousPosition); + } + swiped = false; + } + if (videos != null && videos.size() > 0) { + this.peertubes.addAll(videos); + peertubeAdapter.notifyItemRangeInserted(previousPosition, videos.size()); + } + swipeRefreshLayout.setRefreshing(false); + firstLoad = false; + } + } +} diff --git a/app/src/main/java/app/fedilab/fedilabtube/asynctasks/ManagePlaylistsAsyncTask.java b/app/src/main/java/app/fedilab/fedilabtube/asynctasks/ManagePlaylistsAsyncTask.java index b319d08..1326695 100644 --- a/app/src/main/java/app/fedilab/fedilabtube/asynctasks/ManagePlaylistsAsyncTask.java +++ b/app/src/main/java/app/fedilab/fedilabtube/asynctasks/ManagePlaylistsAsyncTask.java @@ -45,7 +45,7 @@ public class ManagePlaylistsAsyncTask extends AsyncTask { String instance = sharedpreferences.getString(Helper.PREF_INSTANCE, Helper.getLiveInstance(contextReference.get())); SQLiteDatabase db = Sqlite.getInstance(contextReference.get().getApplicationContext(), Sqlite.DB_NAME, null, Sqlite.DB_VERSION).open(); Account account = new AccountDAO(contextReference.get(), db).getUniqAccount(userId, instance); - if( account == null) { + if (account == null) { account = new AccountDAO(contextReference.get(), db).getUniqAccount(userId, Helper.getPeertubeUrl(instance)); } if (account == null) { diff --git a/app/src/main/java/app/fedilab/fedilabtube/drawer/PlaylistAdapter.java b/app/src/main/java/app/fedilab/fedilabtube/drawer/PlaylistAdapter.java new file mode 100644 index 0000000..9e9e71a --- /dev/null +++ b/app/src/main/java/app/fedilab/fedilabtube/drawer/PlaylistAdapter.java @@ -0,0 +1,125 @@ +package app.fedilab.fedilabtube.drawer; + + +import android.app.AlertDialog; +import android.content.Context; +import android.content.Intent; +import android.graphics.drawable.Drawable; +import android.os.AsyncTask; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.LinearLayout; +import android.widget.RelativeLayout; +import android.widget.TextView; + +import androidx.core.content.ContextCompat; + +import java.util.List; + +import app.fedilab.fedilabtube.PlaylistsActivity; +import app.fedilab.fedilabtube.R; +import app.fedilab.fedilabtube.asynctasks.ManagePlaylistsAsyncTask; +import app.fedilab.fedilabtube.client.APIResponse; +import app.fedilab.fedilabtube.client.entities.Playlist; +import app.fedilab.fedilabtube.interfaces.OnPlaylistActionInterface; + + +public class PlaylistAdapter extends BaseAdapter implements OnPlaylistActionInterface { + + private List playlists; + private LayoutInflater layoutInflater; + private Context context; + private PlaylistAdapter playlistAdapter; + private RelativeLayout textviewNoAction; + + public PlaylistAdapter(Context context, List lists, RelativeLayout textviewNoAction) { + this.playlists = lists; + layoutInflater = LayoutInflater.from(context); + this.context = context; + playlistAdapter = this; + this.textviewNoAction = textviewNoAction; + } + + @Override + public int getCount() { + return playlists.size(); + } + + @Override + public Object getItem(int position) { + return playlists.get(position); + } + + @Override + public long getItemId(int position) { + return position; + } + + + @Override + public View getView(final int position, View convertView, ViewGroup parent) { + + final Playlist playlist = playlists.get(position); + final ViewHolder holder; + if (convertView == null) { + convertView = layoutInflater.inflate(R.layout.drawer_playlist, parent, false); + holder = new ViewHolder(); + holder.search_title = convertView.findViewById(R.id.search_keyword); + holder.search_container = convertView.findViewById(R.id.search_container); + convertView.setTag(holder); + } else { + holder = (ViewHolder) convertView.getTag(); + } + + + Drawable next = ContextCompat.getDrawable(context, R.drawable.ic_baseline_arrow_forward_ios_24); + holder.search_title.setText(playlist.getDisplayName()); + assert next != null; + final float scale = context.getResources().getDisplayMetrics().density; + next.setBounds(0, 0, (int) (30 * scale + 0.5f), (int) (30 * scale + 0.5f)); + holder.search_title.setCompoundDrawables(null, null, next, null); + + holder.search_container.setOnClickListener(v -> { + Intent intent = new Intent(context, PlaylistsActivity.class); + Bundle b = new Bundle(); + b.putParcelable("playlist", playlist); + intent.putExtras(b); + context.startActivity(intent); + }); + + holder.search_container.setOnLongClickListener(v -> { + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setTitle(context.getString(R.string.action_lists_delete) + ": " + playlist.getDisplayName()); + builder.setMessage(context.getString(R.string.action_lists_confirm_delete)); + builder.setIcon(android.R.drawable.ic_dialog_alert) + .setPositiveButton(R.string.yes, (dialog, which) -> { + playlists.remove(playlist); + playlistAdapter.notifyDataSetChanged(); + new ManagePlaylistsAsyncTask(context, ManagePlaylistsAsyncTask.action.DELETE_PLAYLIST, playlist, null, null, PlaylistAdapter.this).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + if (playlists.size() == 0 && textviewNoAction != null && textviewNoAction.getVisibility() == View.GONE) + textviewNoAction.setVisibility(View.VISIBLE); + dialog.dismiss(); + }) + .setNegativeButton(R.string.no, (dialog, which) -> dialog.dismiss()) + .show(); + + return false; + }); + return convertView; + } + + @Override + public void onActionDone(ManagePlaylistsAsyncTask.action actionType, APIResponse apiResponse, int statusCode) { + + } + + private static class ViewHolder { + LinearLayout search_container; + TextView search_title; + } + + +} \ No newline at end of file diff --git a/app/src/main/java/app/fedilab/fedilabtube/fragment/DisplayPlaylistsFragment.java b/app/src/main/java/app/fedilab/fedilabtube/fragment/DisplayPlaylistsFragment.java new file mode 100644 index 0000000..18a8e0e --- /dev/null +++ b/app/src/main/java/app/fedilab/fedilabtube/fragment/DisplayPlaylistsFragment.java @@ -0,0 +1,417 @@ +package app.fedilab.fedilabtube.fragment; + +import android.app.Activity; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.AsyncTask; +import android.os.Bundle; +import android.text.InputFilter; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowManager; +import android.view.inputmethod.InputMethodManager; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.ListView; +import android.widget.RelativeLayout; +import android.widget.Spinner; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentTransaction; + +import com.google.android.material.floatingactionbutton.FloatingActionButton; + +import net.gotev.uploadservice.MultipartUploadRequest; +import net.gotev.uploadservice.ServerResponse; +import net.gotev.uploadservice.UploadInfo; +import net.gotev.uploadservice.UploadNotificationConfig; +import net.gotev.uploadservice.UploadStatusDelegate; + +import org.jetbrains.annotations.NotNull; + +import java.net.MalformedURLException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import app.fedilab.fedilabtube.PlaylistsActivity; +import app.fedilab.fedilabtube.R; +import app.fedilab.fedilabtube.asynctasks.ManagePlaylistsAsyncTask; +import app.fedilab.fedilabtube.asynctasks.RetrievePeertubeChannelsAsyncTask; +import app.fedilab.fedilabtube.client.APIResponse; +import app.fedilab.fedilabtube.client.entities.Account; +import app.fedilab.fedilabtube.client.entities.Playlist; +import app.fedilab.fedilabtube.drawer.PlaylistAdapter; +import app.fedilab.fedilabtube.helper.Helper; +import app.fedilab.fedilabtube.interfaces.OnPlaylistActionInterface; +import app.fedilab.fedilabtube.interfaces.OnRetrievePeertubeInterface; +import es.dmoral.toasty.Toasty; + +import static app.fedilab.fedilabtube.asynctasks.RetrievePeertubeInformationAsyncTask.peertubeInformation; + + +public class DisplayPlaylistsFragment extends Fragment implements OnPlaylistActionInterface, OnRetrievePeertubeInterface { + + + private Context context; + private AsyncTask asyncTask; + private List playlists; + private RelativeLayout mainLoader; + private FloatingActionButton add_new; + private PlaylistAdapter playlistAdapter; + private RelativeLayout textviewNoAction; + private HashMap privacyToSend; + private HashMap channelToSend; + private Spinner set_upload_channel; + private Spinner set_upload_privacy; + private HashMap channels; + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + + //View for fragment is the same that fragment accounts + View rootView = inflater.inflate(R.layout.fragment_playlists, container, false); + + context = getContext(); + playlists = new ArrayList<>(); + + + ListView lv_playlist = rootView.findViewById(R.id.lv_playlist); + textviewNoAction = rootView.findViewById(R.id.no_action); + mainLoader = rootView.findViewById(R.id.loader); + RelativeLayout nextElementLoader = rootView.findViewById(R.id.loading_next_items); + mainLoader.setVisibility(View.VISIBLE); + nextElementLoader.setVisibility(View.GONE); + playlists = new ArrayList<>(); + playlistAdapter = new PlaylistAdapter(context, playlists, textviewNoAction); + lv_playlist.setAdapter(playlistAdapter); + asyncTask = new ManagePlaylistsAsyncTask(context, ManagePlaylistsAsyncTask.action.GET_PLAYLIST, null, null, null, DisplayPlaylistsFragment.this).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + add_new = rootView.findViewById(R.id.add_new); + + + LinkedHashMap translations = null; + if (peertubeInformation != null && peertubeInformation.getTranslations() != null) + translations = new LinkedHashMap<>(peertubeInformation.getTranslations()); + + LinkedHashMap privaciesInit = new LinkedHashMap<>(peertubeInformation.getPrivacies()); + Map.Entry entryInt = privaciesInit.entrySet().iterator().next(); + privacyToSend = new HashMap<>(); + privacyToSend.put(entryInt.getKey(), entryInt.getValue()); + LinkedHashMap privacies = new LinkedHashMap<>(peertubeInformation.getPrivacies()); + //Populate privacies + Iterator> it = privacies.entrySet().iterator(); + while (it.hasNext()) { + it.remove(); + } + + if (add_new != null) { + add_new.setOnClickListener(view -> { + final SharedPreferences sharedpreferences = context.getSharedPreferences(Helper.APP_PREFS, Context.MODE_PRIVATE); + AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(context); + LayoutInflater inflater1 = ((Activity) context).getLayoutInflater(); + View dialogView = inflater1.inflate(R.layout.add_playlist, new LinearLayout(context), false); + dialogBuilder.setView(dialogView); + EditText display_name = dialogView.findViewById(R.id.display_name); + EditText description = dialogView.findViewById(R.id.description); + set_upload_channel = dialogView.findViewById(R.id.set_upload_channel); + set_upload_privacy = dialogView.findViewById(R.id.set_upload_privacy); + + + new RetrievePeertubeChannelsAsyncTask(context, DisplayPlaylistsFragment.this).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + + display_name.setFilters(new InputFilter[]{new InputFilter.LengthFilter(120)}); + description.setFilters(new InputFilter[]{new InputFilter.LengthFilter(1000)}); + + dialogBuilder.setPositiveButton(R.string.validate, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int id) { + + if (display_name.getText() != null && display_name.getText().toString().trim().length() > 0) { + + Playlist playlist = new Playlist(); + playlist.setDisplayName(display_name.getText().toString().trim()); + if (description.getText() != null && description.getText().toString().trim().length() > 0) { + playlist.setDescription(description.getText().toString().trim()); + } + String idChannel = null; + if (channelToSend != null) { + Map.Entry channelM = channelToSend.entrySet().iterator().next(); + idChannel = channelM.getValue(); + if (idChannel.length() > 0) + playlist.setVideoChannelId(idChannel); + } + Map.Entry privacyM = privacyToSend.entrySet().iterator().next(); + String label = privacyM.getValue(); + String idPrivacy = String.valueOf(privacyM.getKey()); + if (label.equals("Public") && (playlist.getVideoChannelId() == null || playlist.getVideoChannelId().equals(""))) { + Toasty.error(context, context.getString(R.string.error_channel_mandatory), Toast.LENGTH_LONG).show(); + } else { + if (privacyToSend != null) { + playlist.setPrivacy(privacyToSend); + } + //new ManagePlaylistsAsyncTask(context, ManagePlaylistsAsyncTask.action.CREATE_PLAYLIST, playlist, null, null, DisplayPlaylistsFragment.this).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + UploadNotificationConfig uploadConfig = new UploadNotificationConfig(); + uploadConfig.getCompleted().autoClear = true; + try { + String token = sharedpreferences.getString(Helper.PREF_KEY_OAUTH_TOKEN, null); + new MultipartUploadRequest(context, "https://" + Helper.getLiveInstance(context) + "/api/v1/video-playlists/") + //.addFileToUpload(uri.toString().replace("file://",""), "videofile") + .addHeader("Authorization", "Bearer " + token) + .setNotificationConfig(uploadConfig) + // .addParameter("name", filename) + .addParameter("videoChannelId", idChannel) + .addParameter("privacy", idPrivacy) + .addParameter("displayName", playlist.getDisplayName()) + .addParameter("description", playlist.getDescription()) + .setMaxRetries(1) + .setDelegate(new UploadStatusDelegate() { + @Override + public void onProgress(Context context, UploadInfo uploadInfo) { + // your code here + } + + @Override + public void onError(Context context, UploadInfo uploadInfo, ServerResponse serverResponse, + Exception exception) { + // your code here + exception.printStackTrace(); + } + + @Override + public void onCompleted(Context context, UploadInfo uploadInfo, ServerResponse serverResponse) { + DisplayPlaylistsFragment displayPlaylistsFragment; + if (getActivity() == null) + return; + displayPlaylistsFragment = (DisplayPlaylistsFragment) getActivity().getSupportFragmentManager().findFragmentByTag("PLAYLISTS"); + final FragmentTransaction ft = getActivity().getSupportFragmentManager().beginTransaction(); + if (displayPlaylistsFragment != null) { + ft.detach(displayPlaylistsFragment); + ft.attach(displayPlaylistsFragment); + ft.commit(); + } + } + + @Override + public void onCancelled(Context context, UploadInfo uploadInfo) { + // your code here + } + }) + .startUpload(); + } catch (MalformedURLException e) { + e.printStackTrace(); + } + + dialog.dismiss(); + add_new.setEnabled(false); + } + } else { + Toasty.error(context, context.getString(R.string.error_display_name), Toast.LENGTH_LONG).show(); + } + + } + }); + dialogBuilder.setNegativeButton(R.string.cancel, (dialog, id) -> dialog.dismiss()); + + + AlertDialog alertDialog = dialogBuilder.create(); + alertDialog.setTitle(getString(R.string.action_playlist_create)); + alertDialog.setOnDismissListener(dialogInterface -> { + //Hide keyboard + InputMethodManager imm = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); + assert imm != null; + imm.hideSoftInputFromWindow(display_name.getWindowToken(), 0); + }); + if (alertDialog.getWindow() != null) + alertDialog.getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE); + alertDialog.show(); + }); + } + return rootView; + } + + + @Override + public void onCreate(Bundle saveInstance) { + super.onCreate(saveInstance); + } + + + @Override + public void onAttach(@NotNull Context context) { + super.onAttach(context); + this.context = context; + } + + public void onDestroy() { + super.onDestroy(); + if (asyncTask != null && asyncTask.getStatus() == AsyncTask.Status.RUNNING) + asyncTask.cancel(true); + } + + + @Override + public void onActionDone(ManagePlaylistsAsyncTask.action actionType, APIResponse apiResponse, int statusCode) { + mainLoader.setVisibility(View.GONE); + add_new.setEnabled(true); + if (apiResponse.getError() != null) { + Toasty.error(context, apiResponse.getError().getError(), Toast.LENGTH_LONG).show(); + return; + } + + if (actionType == ManagePlaylistsAsyncTask.action.GET_PLAYLIST) { + if (apiResponse.getPlaylists() != null && apiResponse.getPlaylists().size() > 0) { + this.playlists.addAll(apiResponse.getPlaylists()); + playlistAdapter.notifyDataSetChanged(); + textviewNoAction.setVisibility(View.GONE); + } else { + textviewNoAction.setVisibility(View.VISIBLE); + } + } else if (actionType == ManagePlaylistsAsyncTask.action.CREATE_PLAYLIST) { + if (apiResponse.getPlaylists() != null && apiResponse.getPlaylists().size() > 0) { + Intent intent = new Intent(context, PlaylistsActivity.class); + Bundle b = new Bundle(); + b.putParcelable("playlist", apiResponse.getPlaylists().get(0)); + intent.putExtras(b); + context.startActivity(intent); + this.playlists.add(0, apiResponse.getPlaylists().get(0)); + playlistAdapter.notifyDataSetChanged(); + textviewNoAction.setVisibility(View.GONE); + } else { + Toasty.error(context, apiResponse.getError().getError(), Toast.LENGTH_LONG).show(); + } + } else if (actionType == ManagePlaylistsAsyncTask.action.DELETE_PLAYLIST) { + if (this.playlists.size() == 0) + textviewNoAction.setVisibility(View.VISIBLE); + } + } + + + @Override + public void onRetrievePeertube(APIResponse apiResponse) { + + } + + @Override + public void onRetrievePeertubeComments(APIResponse apiResponse) { + + } + + @Override + public void onRetrievePeertubeChannels(APIResponse apiResponse) { + if (apiResponse.getError() != null || apiResponse.getAccounts() == null || apiResponse.getAccounts().size() == 0) { + if (apiResponse.getError() != null && apiResponse.getError().getError() != null) + Toasty.error(context, apiResponse.getError().getError(), Toast.LENGTH_LONG).show(); + else + Toasty.error(context, getString(R.string.toast_error), Toast.LENGTH_LONG).show(); + return; + } + + //Populate channels + List accounts = apiResponse.getAccounts(); + String[] channelName = new String[accounts.size() + 1]; + String[] channelId = new String[accounts.size() + 1]; + int i = 1; + channelName[0] = ""; + channelId[0] = ""; + channels = new HashMap<>(); + for (Account account : accounts) { + channels.put(account.getUsername(), account.getId()); + channelName[i] = account.getUsername(); + channelId[i] = account.getId(); + i++; + } + + channelToSend = new HashMap<>(); + channelToSend.put(channelName[0], channelId[0]); + ArrayAdapter adapterChannel = new ArrayAdapter<>(context, + android.R.layout.simple_spinner_dropdown_item, channelName); + set_upload_channel.setAdapter(adapterChannel); + + LinkedHashMap translations = null; + if (peertubeInformation.getTranslations() != null) + translations = new LinkedHashMap<>(peertubeInformation.getTranslations()); + + LinkedHashMap privaciesInit = new LinkedHashMap<>(peertubeInformation.getPlaylistPrivacies()); + Map.Entry entryInt = privaciesInit.entrySet().iterator().next(); + privacyToSend = new HashMap<>(); + privacyToSend.put(entryInt.getKey(), entryInt.getValue()); + LinkedHashMap privacies = new LinkedHashMap<>(peertubeInformation.getPlaylistPrivacies()); + //Populate privacies + String[] privaciesA = new String[privacies.size()]; + Iterator> it = privacies.entrySet().iterator(); + i = 0; + while (it.hasNext()) { + Map.Entry pair = it.next(); + if (translations == null || translations.size() == 0 || !translations.containsKey(pair.getValue())) + privaciesA[i] = pair.getValue(); + else + privaciesA[i] = translations.get(pair.getValue()); + it.remove(); + i++; + } + + ArrayAdapter adapterPrivacies = new ArrayAdapter<>(context, + android.R.layout.simple_spinner_dropdown_item, privaciesA); + set_upload_privacy.setAdapter(adapterPrivacies); + + //Manage privacies + set_upload_privacy.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + LinkedHashMap privaciesCheck = new LinkedHashMap<>(peertubeInformation.getPrivacies()); + Iterator> it = privaciesCheck.entrySet().iterator(); + int i = 0; + while (it.hasNext()) { + Map.Entry pair = it.next(); + if (i == position) { + privacyToSend = new HashMap<>(); + privacyToSend.put(pair.getKey(), pair.getValue()); + break; + } + it.remove(); + i++; + } + } + + @Override + public void onNothingSelected(AdapterView parent) { + + } + }); + //Manage languages + set_upload_channel.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + LinkedHashMap channelsCheck = new LinkedHashMap<>(channels); + Iterator> it = channelsCheck.entrySet().iterator(); + int i = 0; + while (it.hasNext()) { + Map.Entry pair = it.next(); + if (i == position) { + channelToSend = new HashMap<>(); + channelToSend.put(pair.getKey(), pair.getValue()); + break; + } + it.remove(); + i++; + } + } + + @Override + public void onNothingSelected(AdapterView parent) { + + } + }); + } +} diff --git a/app/src/main/res/drawable/ic_baseline_add_24.xml b/app/src/main/res/drawable/ic_baseline_add_24.xml new file mode 100644 index 0000000..0553ae3 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_add_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_arrow_forward_ios_24.xml b/app/src/main/res/drawable/ic_baseline_arrow_forward_ios_24.xml new file mode 100644 index 0000000..edd53d2 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_arrow_forward_ios_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_playlist_play_24.xml b/app/src/main/res/drawable/ic_baseline_playlist_play_24.xml new file mode 100644 index 0000000..c3a8b09 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_playlist_play_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/activity_all_playlist.xml b/app/src/main/res/layout/activity_all_playlist.xml new file mode 100644 index 0000000..2fbfc87 --- /dev/null +++ b/app/src/main/res/layout/activity_all_playlist.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_playlists.xml b/app/src/main/res/layout/activity_playlists.xml new file mode 100644 index 0000000..74f831f --- /dev/null +++ b/app/src/main/res/layout/activity_playlists.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/add_playlist.xml b/app/src/main/res/layout/add_playlist.xml new file mode 100644 index 0000000..224c4ed --- /dev/null +++ b/app/src/main/res/layout/add_playlist.xml @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/drawer_playlist.xml b/app/src/main/res/layout/drawer_playlist.xml new file mode 100644 index 0000000..048b1d1 --- /dev/null +++ b/app/src/main/res/layout/drawer_playlist.xml @@ -0,0 +1,23 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_playlists.xml b/app/src/main/res/layout/fragment_playlists.xml index 03f0241..b423779 100644 --- a/app/src/main/res/layout/fragment_playlists.xml +++ b/app/src/main/res/layout/fragment_playlists.xml @@ -58,5 +58,15 @@ android:layout_height="match_parent" android:indeterminate="true" /> + + diff --git a/app/src/main/res/menu/main_menu.xml b/app/src/main/res/menu/main_menu.xml index d8ccb04..a4206ac 100644 --- a/app/src/main/res/menu/main_menu.xml +++ b/app/src/main/res/menu/main_menu.xml @@ -31,4 +31,9 @@ android:icon="@drawable/ic_baseline_personal_video_24" android:title="@string/my_videos" app:showAsAction="ifRoom" /> + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index cf75b17..8b3e7b2 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -5,4 +5,5 @@ 5dp 2dp 100dp + 16dp \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b557a4f..784f2d7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -145,4 +145,12 @@ Déconnexion Voulez-vous vraiment déconnecter le compte @%1$s ? + Supprimer la liste de lecture + Êtes-vous sûr de vouloir supprimer définitivement cette liste de lecture ? + Créer une liste de lecture + Nom d\'affichage + Un canal est requis lorsque la liste de lecture est publique. + Vous devez fournir un nom d\'affichage ! + Cette liste de lecture est vide. + Listes de lecture \ No newline at end of file