/* Viewer for Khan Academy Copyright (C) 2012 Concentric Sky, Inc. This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see <http://www.gnu.org/licenses/>. */ package com.concentricsky.android.khanacademy.app; import static com.concentricsky.android.khanacademy.Constants.PARAM_VIDEO_ID; import static com.concentricsky.android.khanacademy.Constants.PARAM_VIDEO_POSITION; import java.sql.SQLException; import java.util.List; import android.app.Fragment; import android.graphics.Color; import android.os.AsyncTask; import android.os.Bundle; import android.os.Handler; import android.view.InflateException; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.AbsListView; import android.widget.AdapterView; import android.widget.ArrayAdapter; import android.widget.LinearLayout; import android.widget.ListAdapter; import android.widget.ListView; import android.widget.TextView; import com.concentricsky.android.khan.R; import com.concentricsky.android.khanacademy.data.KADataService; import com.concentricsky.android.khanacademy.data.db.Caption; import com.concentricsky.android.khanacademy.data.db.Video; import com.concentricsky.android.khanacademy.util.Log; import com.concentricsky.android.khanacademy.util.ObjectCallback; import com.j256.ormlite.dao.Dao; import com.j256.ormlite.stmt.QueryBuilder; public class CaptionFragment extends Fragment implements AbsListView.OnScrollListener { public static final String LOG_TAG = CaptionFragment.class.getSimpleName(); public static final int SCROLL_OFFSCREEN_DELAY = 30000; public static final int SCROLL_ONSCREEN_DELAY = 5000; public static final int NEVER = -1; public static final int NOW = 0; public interface Callbacks { public void onPositionRequested(int ms); public void onDownloadRequested(Video video); public void onCaptionsUnavailable(); public void onCaptionsLoaded(); } // For use in AsyncTasks, which should not do most things after the activity/fragment has been destroyed. private boolean destroyed = false; private LinearLayout containerView; private View loadingView; private View emptyView; private ListView listView; private Video video; private Callbacks callbacks; private List<Caption> captions; private boolean tracking = true; private int currentPosition = 0; private Runnable startTracking = new Runnable() { @Override public void run() { tracking = true; } }; private Handler handler = new Handler(); @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); } @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { containerView = (LinearLayout) inflater.inflate(R.layout.fragment_captions, null, false); loadingView = containerView.findViewById(R.id.loading_captions); emptyView = containerView.findViewById(R.id.empty_captions); ((TextView) emptyView.findViewById(R.id.text_captions_empty)).setText(R.string.msg_captions_loading); listView = (ListView) containerView.findViewById(android.R.id.list); return containerView; } @Override public void onActivityCreated(Bundle savedInstanceState) { super.onActivityCreated(savedInstanceState); listView.setChoiceMode(ListView.CHOICE_MODE_SINGLE); listView.setOnScrollListener(this); listView.setBackgroundColor(Color.WHITE); listView.setOnItemClickListener(itemClickListener); // TODO : saved value, default, ... Bundle args = getArguments(); final String videoId = args.getString(PARAM_VIDEO_ID); ((KADataService.Provider) getActivity()).requestDataService( new ObjectCallback<KADataService>() { @Override public void call(KADataService dataService) { if (destroyed) return; // dataService.getAPIAdapter().registerUserUpdateListener(userUpdateListener); try { Dao<Video, String> videoDao = dataService.getHelper().getVideoDao(); QueryBuilder<Video, String> q = videoDao.queryBuilder(); q.where().eq("readable_id", videoId); video = videoDao.queryForFirst(q.prepare()); if (video != null) { // Grab captions. new CaptionFetcher(dataService).executeOnExecutor( AsyncTask.THREAD_POOL_EXECUTOR, video.getYoutube_id()); // TODO : user points } int pos = getArguments().getInt(PARAM_VIDEO_POSITION, 0); setCurrentPosition(pos); } catch (SQLException e) { // TODO Auto-generated catch block e.printStackTrace(); } } } ); } @Override public void onDestroy() { destroyed = true; // TODO : This may need to go in pause/resume // getListView().setOnScrollListener(null); super.onDestroy(); } class CaptionFetcher extends AsyncTask<String, Void, List<Caption>> { KADataService dataService; CaptionFetcher(KADataService dataService) { this.dataService = dataService; } @Override protected List<Caption> doInBackground(String... params) { String youtubeId = params[0]; return dataService.getCaptionManager().getCaptions(youtubeId); } @Override protected void onPostExecute(List<Caption> result) { if (destroyed) return; if (result != null && result.size() > 0) { // We have captions, so we want a ListView. ListAdapter adapter = new CaptionAdapter(result); listView.setAdapter(adapter); loadingView.setVisibility(View.GONE); emptyView.setVisibility(View.GONE); listView.setVisibility(View.VISIBLE); if (callbacks != null) { callbacks.onCaptionsLoaded(); } } else { // No captions. We need a "no captions" message. loadingView.setVisibility(View.GONE); // TODO : Vary the message for no captions exist / error retrieving captions. ((TextView) emptyView.findViewById(R.id.text_captions_empty)).setText(R.string.msg_captions_unavailable); emptyView.setVisibility(View.VISIBLE); listView.setVisibility(View.GONE); if (callbacks != null) { callbacks.onCaptionsUnavailable(); } } captions = result; } } private class CaptionAdapter extends ArrayAdapter<Caption> { final LayoutInflater inflater; public CaptionAdapter(List<Caption> objects) { super(getActivity(), R.layout.list_item_caption, objects); inflater = getActivity().getLayoutInflater(); } @Override public View getView(int position, View convertView, ViewGroup parent) { if (convertView == null) { try { convertView = inflater.inflate( R.layout.list_item_caption, parent, false); } catch (InflateException e) { e.printStackTrace(); return null; } } TextView time = (TextView) convertView.findViewById(R.id.caption_time); TextView text = (TextView) convertView.findViewById(R.id.caption_text); if (time == null || text == null) { // Have seen some inexplicable NPEs somewhere in this method (line // number reads 368, which was the last line of this file.) return convertView; } Caption caption = getItem(position); if (caption != null) { time.setText(caption.getTime_string()); text.setText(caption.getText()); } else { time.setText(""); text.setText(""); } return convertView; } @Override public boolean isEmpty() { boolean result = captions == null || captions.size() == 0; Log.d(LOG_TAG, String.format("Adapter.isEmpty: %b", result)); return result; } } public void onVideoStarted() { // TODO Auto-generated method stub } public void onVideoStopped() { // TODO Auto-generated method stub } public void onPositionUpdate(int ms) { int position = getPositionForTime(ms); if (position != currentPosition) { setCurrentPosition(position); if (tracking) { trackToPosition(position); } else if (isItemVisible(position)) { startTrackingIn(SCROLL_ONSCREEN_DELAY); } } } @Override public void onScrollStateChanged(AbsListView view, int scrollState) { Log.d(LOG_TAG, "onScrollStateChanged: " + scrollState); switch (scrollState) { // If user has begun scrolling manually, stop tracking. case AbsListView.OnScrollListener.SCROLL_STATE_TOUCH_SCROLL: stopTracking(); break; // If the scroll has finished, start tracking after a delay. case AbsListView.OnScrollListener.SCROLL_STATE_IDLE: startTrackingIn(SCROLL_OFFSCREEN_DELAY); break; } } @Override public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) { // TODO Auto-generated method stub Log.v(LOG_TAG, "onScroll"); } private int getPositionForTime(int ms) { int result = -1; if (captions != null) { int N=captions.size(); for (int i=0; i<N; ++i) { Caption c = captions.get(i); if (c.getStart_time() < ms || result < 0) { result = i; } else { break; } } } return result; } private void setCurrentPosition(int position) { currentPosition = position; if (listView != null) { // uncheck other items listView.clearChoices(); // check correct item if (position >= 0) { listView.setItemChecked(position, true); } } } private void trackToPosition(int position) { // This can happen if we rotate while the video is playing. The caption fragment is still being // created, and gets a callback for video position. We do not need to store the seek position // and do it once the view is constructed, because the callback comes very often and will happen // soon after construction anyway. if (listView != null) { // I think there was a good reason to use setSelection.. on Nexus 7, but smoothScrollTo... works fine on Fire HD. // listView.setSelectionFromTop(position, listView.getMeasuredHeight() / 2); listView.smoothScrollToPositionFromTop(position, listView.getMeasuredHeight() / 2); } } private boolean isItemVisible(int position) { return listView.getFirstVisiblePosition() <= position && position <= listView.getLastVisiblePosition(); } private void startTrackingIn(int ms) { if (ms >= 0) { handler.postDelayed(startTracking, ms); } } private void stopTracking() { tracking = false; handler.removeCallbacks(startTracking); } private AdapterView.OnItemClickListener itemClickListener = new AdapterView.OnItemClickListener() { @Override public void onItemClick(AdapterView<?> parent, View view, int position, long id) { startTrackingIn(NOW); setCurrentPosition(position); if (callbacks != null) { int ms = ((Caption) listView.getAdapter().getItem(position)).getStart_time(); callbacks.onPositionRequested(ms); } } }; public void registerCallbacks(Callbacks callbacks) { this.callbacks = callbacks; } }