diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f6708fa83..a184dd83d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,8 +72,8 @@ jobs: - api-level: 21 target: default arch: x86 - - api-level: 33 - target: google_apis # emulator API 33 only exists with Google APIs + - api-level: 35 + target: default arch: x86_64 permissions: diff --git a/README.md b/README.md index c19144064..aa4332165 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,8 @@

- - - - - - + + diff --git a/app/build.gradle b/app/build.gradle index c5525552f..c2a4f9265 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -17,20 +17,20 @@ plugins { } android { - compileSdk 35 + compileSdk 36 namespace 'org.schabi.newpipe' defaultConfig { applicationId "org.schabi.newpipe" resValue "string", "app_name", "NewPipe" minSdk 21 - targetSdk 33 + targetSdk 35 if (System.properties.containsKey('versionCodeOverride')) { versionCode System.getProperty('versionCodeOverride') as Integer } else { - versionCode 1004 + versionCode 1005 } - versionName "0.27.7" + versionName "0.28.0" if (System.properties.containsKey('versionNameSuffix')) { versionNameSuffix System.getProperty('versionNameSuffix') } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3faa59f7d..0ac368898 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -10,6 +10,7 @@ + @@ -58,6 +59,15 @@ + + + + - + + - + - implements BackPressable, - PlayerServiceExtendedEventListener, - OnKeyDownListener { - public static final String KEY_SWITCHING_PLAYERS = "switching_players"; - - private static final float MAX_OVERLAY_ALPHA = 0.9f; - private static final float MAX_PLAYER_HEIGHT = 0.7f; - - public static final String ACTION_SHOW_MAIN_PLAYER = - App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_SHOW_MAIN_PLAYER"; - public static final String ACTION_HIDE_MAIN_PLAYER = - App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_HIDE_MAIN_PLAYER"; - public static final String ACTION_PLAYER_STARTED = - App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_PLAYER_STARTED"; - public static final String ACTION_VIDEO_FRAGMENT_RESUMED = - App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_VIDEO_FRAGMENT_RESUMED"; - public static final String ACTION_VIDEO_FRAGMENT_STOPPED = - App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_VIDEO_FRAGMENT_STOPPED"; - - private static final String COMMENTS_TAB_TAG = "COMMENTS"; - private static final String RELATED_TAB_TAG = "NEXT VIDEO"; - private static final String DESCRIPTION_TAB_TAG = "DESCRIPTION TAB"; - private static final String EMPTY_TAB_TAG = "EMPTY TAB"; - - // tabs - private boolean showComments; - private boolean showRelatedItems; - private boolean showDescription; - private String selectedTabTag; - @AttrRes - @NonNull - final List tabIcons = new ArrayList<>(); - @StringRes - @NonNull - final List tabContentDescriptions = new ArrayList<>(); - private boolean tabSettingsChanged = false; - private int lastAppBarVerticalOffset = Integer.MAX_VALUE; // prevents useless updates - - private final SharedPreferences.OnSharedPreferenceChangeListener preferenceChangeListener = - (sharedPreferences, key) -> { - if (getString(R.string.show_comments_key).equals(key)) { - showComments = sharedPreferences.getBoolean(key, true); - tabSettingsChanged = true; - } else if (getString(R.string.show_next_video_key).equals(key)) { - showRelatedItems = sharedPreferences.getBoolean(key, true); - tabSettingsChanged = true; - } else if (getString(R.string.show_description_key).equals(key)) { - showDescription = sharedPreferences.getBoolean(key, true); - tabSettingsChanged = true; - } - }; - - @State - int serviceId = Constants.NO_SERVICE_ID; - @State - @NonNull - String title = ""; - @State - @Nullable - String url = null; - @Nullable - private PlayQueue playQueue = null; - @State - int bottomSheetState = BottomSheetBehavior.STATE_EXPANDED; - @State - int lastStableBottomSheetState = BottomSheetBehavior.STATE_EXPANDED; - @State - boolean autoPlayEnabled = true; - - @Nullable - private StreamInfo currentInfo = null; - private Disposable currentWorker; - @NonNull - private final CompositeDisposable disposables = new CompositeDisposable(); - @Nullable - private Disposable positionSubscriber = null; - - private BottomSheetBehavior bottomSheetBehavior; - private BottomSheetBehavior.BottomSheetCallback bottomSheetCallback; - private BroadcastReceiver broadcastReceiver; - - /*////////////////////////////////////////////////////////////////////////// - // Views - //////////////////////////////////////////////////////////////////////////*/ - - private FragmentVideoDetailBinding binding; - - private TabAdapter pageAdapter; - - private ContentObserver settingsContentObserver; - @Nullable - private PlayerService playerService; - private Player player; - private final PlayerHolder playerHolder = PlayerHolder.getInstance(); - - /*////////////////////////////////////////////////////////////////////////// - // Service management - //////////////////////////////////////////////////////////////////////////*/ - @Override - public void onServiceConnected(@NonNull final PlayerService connectedPlayerService) { - playerService = connectedPlayerService; - } - - @Override - public void onPlayerConnected(@NonNull final Player connectedPlayer, - final boolean playAfterConnect) { - player = connectedPlayer; - - // It will do nothing if the player is not in fullscreen mode - hideSystemUiIfNeeded(); - - final Optional playerUi = player.UIs().getOpt(MainPlayerUi.class); - if (!player.videoPlayerSelected() && !playAfterConnect) { - return; - } - - if (DeviceUtils.isLandscape(requireContext())) { - // If the video is playing but orientation changed - // let's make the video in fullscreen again - checkLandscape(); - } else if (playerUi.map(ui -> ui.isFullscreen() && !ui.isVerticalVideo()).orElse(false) - // Tablet UI has orientation-independent fullscreen - && !DeviceUtils.isTablet(activity)) { - // Device is in portrait orientation after rotation but UI is in fullscreen. - // Return back to non-fullscreen state - playerUi.ifPresent(MainPlayerUi::toggleFullscreen); - } - - if (playAfterConnect - || (currentInfo != null - && isAutoplayEnabled() - && playerUi.isEmpty())) { - autoPlayEnabled = true; // forcefully start playing - openVideoPlayerAutoFullscreen(); - } - updateOverlayPlayQueueButtonVisibility(); - } - - @Override - public void onPlayerDisconnected() { - player = null; - // the binding could be null at this point, if the app is finishing - if (binding != null) { - restoreDefaultBrightness(); - } - } - - @Override - public void onServiceDisconnected() { - playerService = null; - } - - - /*////////////////////////////////////////////////////////////////////////*/ - - public static VideoDetailFragment getInstance(final int serviceId, - @Nullable final String url, - @NonNull final String name, - @Nullable final PlayQueue queue) { - final VideoDetailFragment instance = new VideoDetailFragment(); - instance.setInitialData(serviceId, url, name, queue); - return instance; - } - - public static VideoDetailFragment getInstanceInCollapsedState() { - final VideoDetailFragment instance = new VideoDetailFragment(); - instance.updateBottomSheetState(BottomSheetBehavior.STATE_COLLAPSED); - return instance; - } - - - /*////////////////////////////////////////////////////////////////////////// - // Fragment's Lifecycle - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onCreate(final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(activity); - showComments = prefs.getBoolean(getString(R.string.show_comments_key), true); - showRelatedItems = prefs.getBoolean(getString(R.string.show_next_video_key), true); - showDescription = prefs.getBoolean(getString(R.string.show_description_key), true); - selectedTabTag = prefs.getString( - getString(R.string.stream_info_selected_tab_key), COMMENTS_TAB_TAG); - prefs.registerOnSharedPreferenceChangeListener(preferenceChangeListener); - - setupBroadcastReceiver(); - - settingsContentObserver = new ContentObserver(new Handler()) { - @Override - public void onChange(final boolean selfChange) { - if (activity != null && !globalScreenOrientationLocked(activity)) { - activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED); - } - } - }; - activity.getContentResolver().registerContentObserver( - Settings.System.getUriFor(Settings.System.ACCELEROMETER_ROTATION), false, - settingsContentObserver); - } - - @Override - public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, - final Bundle savedInstanceState) { - binding = FragmentVideoDetailBinding.inflate(inflater, container, false); - return binding.getRoot(); - } - - @Override - public void onPause() { - super.onPause(); - if (currentWorker != null) { - currentWorker.dispose(); - } - restoreDefaultBrightness(); - PreferenceManager.getDefaultSharedPreferences(requireContext()) - .edit() - .putString(getString(R.string.stream_info_selected_tab_key), - pageAdapter.getItemTitle(binding.viewPager.getCurrentItem())) - .apply(); - } - - @Override - public void onResume() { - super.onResume(); - if (DEBUG) { - Log.d(TAG, "onResume() called"); - } - - activity.sendBroadcast(new Intent(ACTION_VIDEO_FRAGMENT_RESUMED)); - - updateOverlayPlayQueueButtonVisibility(); - - setupBrightness(); - - if (tabSettingsChanged) { - tabSettingsChanged = false; - initTabs(); - if (currentInfo != null) { - updateTabs(currentInfo); - } - } - - // Check if it was loading when the fragment was stopped/paused - if (wasLoading.getAndSet(false) && !wasCleared()) { - startLoading(false); - } - } - - @Override - public void onStop() { - super.onStop(); - - if (!activity.isChangingConfigurations()) { - activity.sendBroadcast(new Intent(ACTION_VIDEO_FRAGMENT_STOPPED)); - } - } - - @Override - public void onDestroy() { - super.onDestroy(); - - // Stop the service when user leaves the app with double back press - // if video player is selected. Otherwise unbind - if (activity.isFinishing() && isPlayerAvailable() && player.videoPlayerSelected()) { - playerHolder.stopService(); - } else { - playerHolder.setListener(null); - } - - PreferenceManager.getDefaultSharedPreferences(activity) - .unregisterOnSharedPreferenceChangeListener(preferenceChangeListener); - activity.unregisterReceiver(broadcastReceiver); - activity.getContentResolver().unregisterContentObserver(settingsContentObserver); - - if (positionSubscriber != null) { - positionSubscriber.dispose(); - } - if (currentWorker != null) { - currentWorker.dispose(); - } - disposables.clear(); - positionSubscriber = null; - currentWorker = null; - bottomSheetBehavior.removeBottomSheetCallback(bottomSheetCallback); - - if (activity.isFinishing()) { - playQueue = null; - currentInfo = null; - stack = new LinkedList<>(); - } - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - binding = null; - } - - @Override - public void onActivityResult(final int requestCode, final int resultCode, final Intent data) { - super.onActivityResult(requestCode, resultCode, data); - if (requestCode == ReCaptchaActivity.RECAPTCHA_REQUEST) { - if (resultCode == Activity.RESULT_OK) { - NavigationHelper.openVideoDetailFragment(requireContext(), getFM(), - serviceId, url, title, null, false); - } else { - Log.e(TAG, "ReCaptcha failed"); - } - } else { - Log.e(TAG, "Request code from activity not supported [" + requestCode + "]"); - } - } - - /*////////////////////////////////////////////////////////////////////////// - // OnClick - //////////////////////////////////////////////////////////////////////////*/ - - private void setOnClickListeners() { - binding.detailTitleRootLayout.setOnClickListener(v -> toggleTitleAndSecondaryControls()); - binding.detailUploaderRootLayout.setOnClickListener(makeOnClickListener(info -> { - if (isEmpty(info.getSubChannelUrl())) { - if (!isEmpty(info.getUploaderUrl())) { - openChannel(info.getUploaderUrl(), info.getUploaderName()); - } - - if (DEBUG) { - Log.i(TAG, "Can't open sub-channel because we got no channel URL"); - } - } else { - openChannel(info.getSubChannelUrl(), info.getSubChannelName()); - } - })); - binding.detailThumbnailRootLayout.setOnClickListener(v -> { - autoPlayEnabled = true; // forcefully start playing - // FIXME Workaround #7427 - if (isPlayerAvailable()) { - player.setRecovery(); - } - openVideoPlayerAutoFullscreen(); - }); - - binding.detailControlsBackground.setOnClickListener(v -> openBackgroundPlayer(false)); - binding.detailControlsPopup.setOnClickListener(v -> openPopupPlayer(false)); - binding.detailControlsPlaylistAppend.setOnClickListener(makeOnClickListener(info -> { - if (getFM() != null && currentInfo != null) { - final Fragment fragment = getParentFragmentManager(). - findFragmentById(R.id.fragment_holder); - - // commit previous pending changes to database - if (fragment instanceof LocalPlaylistFragment) { - ((LocalPlaylistFragment) fragment).saveImmediate(); - } else if (fragment instanceof MainFragment) { - ((MainFragment) fragment).commitPlaylistTabs(); - } - - disposables.add(PlaylistDialog.createCorrespondingDialog(requireContext(), - List.of(new StreamEntity(info)), - dialog -> dialog.show(getParentFragmentManager(), TAG))); - } - })); - binding.detailControlsDownload.setOnClickListener(v -> { - if (PermissionHelper.checkStoragePermissions(activity, - PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE)) { - openDownloadDialog(); - } - }); - binding.detailControlsShare.setOnClickListener(makeOnClickListener(info -> - ShareUtils.shareText(requireContext(), info.getName(), info.getUrl(), - info.getThumbnails()))); - binding.detailControlsOpenInBrowser.setOnClickListener(makeOnClickListener(info -> - ShareUtils.openUrlInBrowser(requireContext(), info.getUrl()))); - binding.detailControlsPlayWithKodi.setOnClickListener(makeOnClickListener(info -> - KoreUtils.playWithKore(requireContext(), Uri.parse(info.getUrl())))); - if (DEBUG) { - binding.detailControlsCrashThePlayer.setOnClickListener(v -> - VideoDetailPlayerCrasher.onCrashThePlayer(requireContext(), player)); - } - - final View.OnClickListener overlayListener = v -> bottomSheetBehavior - .setState(BottomSheetBehavior.STATE_EXPANDED); - binding.overlayThumbnail.setOnClickListener(overlayListener); - binding.overlayMetadataLayout.setOnClickListener(overlayListener); - binding.overlayButtonsLayout.setOnClickListener(overlayListener); - binding.overlayCloseButton.setOnClickListener(v -> bottomSheetBehavior - .setState(BottomSheetBehavior.STATE_HIDDEN)); - binding.overlayPlayQueueButton.setOnClickListener(v -> openPlayQueue(requireContext())); - binding.overlayPlayPauseButton.setOnClickListener(v -> { - if (playerIsNotStopped()) { - player.playPause(); - player.UIs().getOpt(VideoPlayerUi.class).ifPresent(ui -> ui.hideControls(0, 0)); - showSystemUi(); - } else { - autoPlayEnabled = true; // forcefully start playing - openVideoPlayer(false); - } - - setOverlayPlayPauseImage(isPlayerAvailable() && player.isPlaying()); - }); - } - - private View.OnClickListener makeOnClickListener(final Consumer consumer) { - return v -> { - if (!isLoading.get() && currentInfo != null) { - consumer.accept(currentInfo); - } - }; - } - - private void setOnLongClickListeners() { - binding.detailTitleRootLayout.setOnLongClickListener(makeOnLongClickListener(info -> - ShareUtils.copyToClipboard(requireContext(), - binding.detailVideoTitleView.getText().toString()))); - binding.detailUploaderRootLayout.setOnLongClickListener(makeOnLongClickListener(info -> { - if (isEmpty(info.getSubChannelUrl())) { - Log.w(TAG, "Can't open parent channel because we got no parent channel URL"); - } else { - openChannel(info.getUploaderUrl(), info.getUploaderName()); - } - })); - - binding.detailControlsBackground.setOnLongClickListener(makeOnLongClickListener(info -> - openBackgroundPlayer(true) - )); - binding.detailControlsPopup.setOnLongClickListener(makeOnLongClickListener(info -> - openPopupPlayer(true) - )); - binding.detailControlsDownload.setOnLongClickListener(makeOnLongClickListener(info -> - NavigationHelper.openDownloads(activity))); - - final View.OnLongClickListener overlayListener = makeOnLongClickListener(info -> - openChannel(info.getUploaderUrl(), info.getUploaderName())); - binding.overlayThumbnail.setOnLongClickListener(overlayListener); - binding.overlayMetadataLayout.setOnLongClickListener(overlayListener); - } - - private View.OnLongClickListener makeOnLongClickListener(final Consumer consumer) { - return v -> { - if (isLoading.get() || currentInfo == null) { - return false; - } - consumer.accept(currentInfo); - return true; - }; - } - - private void openChannel(final String subChannelUrl, final String subChannelName) { - try { - NavigationHelper.openChannelFragment(getFM(), currentInfo.getServiceId(), - subChannelUrl, subChannelName); - } catch (final Exception e) { - ErrorUtil.showUiErrorSnackbar(this, "Opening channel fragment", e); - } - } - - private void toggleTitleAndSecondaryControls() { - if (binding.detailSecondaryControlPanel.getVisibility() == View.GONE) { - binding.detailVideoTitleView.setMaxLines(10); - animateRotation(binding.detailToggleSecondaryControlsView, - VideoPlayerUi.DEFAULT_CONTROLS_DURATION, 180); - binding.detailSecondaryControlPanel.setVisibility(View.VISIBLE); - } else { - binding.detailVideoTitleView.setMaxLines(1); - animateRotation(binding.detailToggleSecondaryControlsView, - VideoPlayerUi.DEFAULT_CONTROLS_DURATION, 0); - binding.detailSecondaryControlPanel.setVisibility(View.GONE); - } - // view pager height has changed, update the tab layout - updateTabLayoutVisibility(); - } - - /*////////////////////////////////////////////////////////////////////////// - // Init - //////////////////////////////////////////////////////////////////////////*/ - - @Override // called from onViewCreated in {@link BaseFragment#onViewCreated} - protected void initViews(final View rootView, final Bundle savedInstanceState) { - super.initViews(rootView, savedInstanceState); - - pageAdapter = new TabAdapter(getChildFragmentManager()); - binding.viewPager.setAdapter(pageAdapter); - binding.tabLayout.setupWithViewPager(binding.viewPager); - - binding.detailThumbnailRootLayout.requestFocus(); - - binding.detailControlsPlayWithKodi.setVisibility( - KoreUtils.shouldShowPlayWithKodi(requireContext(), serviceId) - ? View.VISIBLE - : View.GONE - ); - binding.detailControlsCrashThePlayer.setVisibility( - DEBUG && PreferenceManager.getDefaultSharedPreferences(getContext()) - .getBoolean(getString(R.string.show_crash_the_player_key), false) - ? View.VISIBLE - : View.GONE - ); - accommodateForTvAndDesktopMode(); - } - - @Override - @SuppressLint("ClickableViewAccessibility") - protected void initListeners() { - super.initListeners(); - - setOnClickListeners(); - setOnLongClickListeners(); - - final View.OnTouchListener controlsTouchListener = (view, motionEvent) -> { - if (motionEvent.getAction() == MotionEvent.ACTION_DOWN - && PlayButtonHelper.shouldShowHoldToAppendTip(activity)) { - - animate(binding.touchAppendDetail, true, 250, AnimationType.ALPHA, 0, () -> - animate(binding.touchAppendDetail, false, 1500, AnimationType.ALPHA, 1000)); - } - return false; - }; - binding.detailControlsBackground.setOnTouchListener(controlsTouchListener); - binding.detailControlsPopup.setOnTouchListener(controlsTouchListener); - - binding.appBarLayout.addOnOffsetChangedListener((layout, verticalOffset) -> { - // prevent useless updates to tab layout visibility if nothing changed - if (verticalOffset != lastAppBarVerticalOffset) { - lastAppBarVerticalOffset = verticalOffset; - // the view was scrolled - updateTabLayoutVisibility(); - } - }); - - setupBottomPlayer(); - if (!playerHolder.isBound()) { - setHeightThumbnail(); - } else { - playerHolder.startService(false, this); - } - } - - /*////////////////////////////////////////////////////////////////////////// - // OwnStack - //////////////////////////////////////////////////////////////////////////*/ - - /** - * Stack that contains the "navigation history".
- * The peek is the current video. - */ - private static LinkedList stack = new LinkedList<>(); - - @Override - public boolean onKeyDown(final int keyCode) { - return isPlayerAvailable() - && player.UIs().getOpt(VideoPlayerUi.class) - .map(playerUi -> playerUi.onKeyDown(keyCode)).orElse(false); - } - - @Override - public boolean onBackPressed() { - if (DEBUG) { - Log.d(TAG, "onBackPressed() called"); - } - - // If we are in fullscreen mode just exit from it via first back press - if (isFullscreen()) { - if (!DeviceUtils.isTablet(activity)) { - player.pause(); - } - restoreDefaultOrientation(); - setAutoPlay(false); - return true; - } - - // If we have something in history of played items we replay it here - if (isPlayerAvailable() - && player.getPlayQueue() != null - && player.videoPlayerSelected() - && player.getPlayQueue().previous()) { - return true; // no code here, as previous() was used in the if - } - - // That means that we are on the start of the stack, - if (stack.size() <= 1) { - restoreDefaultOrientation(); - return false; // let MainActivity handle the onBack (e.g. to minimize the mini player) - } - - // Remove top - stack.pop(); - // Get stack item from the new top - setupFromHistoryItem(Objects.requireNonNull(stack.peek())); - - return true; - } - - private void setupFromHistoryItem(final StackItem item) { - setAutoPlay(false); - hideMainPlayerOnLoadingNewStream(); - - setInitialData(item.getServiceId(), item.getUrl(), - item.getTitle() == null ? "" : item.getTitle(), item.getPlayQueue()); - startLoading(false); - - // Maybe an item was deleted in background activity - if (item.getPlayQueue().getItem() == null) { - return; - } - - final PlayQueueItem playQueueItem = item.getPlayQueue().getItem(); - // Update title, url, uploader from the last item in the stack (it's current now) - final boolean isPlayerStopped = !isPlayerAvailable() || player.isStopped(); - if (playQueueItem != null && isPlayerStopped) { - updateOverlayData(playQueueItem.getTitle(), - playQueueItem.getUploader(), playQueueItem.getThumbnails()); - } - } - - /*////////////////////////////////////////////////////////////////////////// - // Info loading and handling - //////////////////////////////////////////////////////////////////////////*/ - - @Override - protected void doInitialLoadLogic() { - if (wasCleared()) { - return; - } - - if (currentInfo == null) { - prepareAndLoadInfo(); - } else { - prepareAndHandleInfoIfNeededAfterDelay(currentInfo, false, 50); - } - } - - public void selectAndLoadVideo(final int newServiceId, - @Nullable final String newUrl, - @NonNull final String newTitle, - @Nullable final PlayQueue newQueue) { - if (isPlayerAvailable() && newQueue != null && playQueue != null - && playQueue.getItem() != null && !playQueue.getItem().getUrl().equals(newUrl)) { - // Preloading can be disabled since playback is surely being replaced. - player.disablePreloadingOfCurrentTrack(); - } - - setInitialData(newServiceId, newUrl, newTitle, newQueue); - startLoading(false, true); - } - - private void prepareAndHandleInfoIfNeededAfterDelay(final StreamInfo info, - final boolean scrollToTop, - final long delay) { - new Handler(Looper.getMainLooper()).postDelayed(() -> { - if (activity == null) { - return; - } - // Data can already be drawn, don't spend time twice - if (info.getName().equals(binding.detailVideoTitleView.getText().toString())) { - return; - } - prepareAndHandleInfo(info, scrollToTop); - }, delay); - } - - private void prepareAndHandleInfo(final StreamInfo info, final boolean scrollToTop) { - if (DEBUG) { - Log.d(TAG, "prepareAndHandleInfo() called with: " - + "info = [" + info + "], scrollToTop = [" + scrollToTop + "]"); - } - - showLoading(); - initTabs(); - - if (scrollToTop) { - scrollToTop(); - } - handleResult(info); - showContent(); - - } - - private void prepareAndLoadInfo() { - scrollToTop(); - startLoading(false); - } - - @Override - public void startLoading(final boolean forceLoad) { - startLoading(forceLoad, null); - } - - private void startLoading(final boolean forceLoad, final @Nullable Boolean addToBackStack) { - super.startLoading(forceLoad); - - initTabs(); - currentInfo = null; - if (currentWorker != null) { - currentWorker.dispose(); - } - - runWorker(forceLoad, addToBackStack != null ? addToBackStack : stack.isEmpty()); - } - - private void runWorker(final boolean forceLoad, final boolean addToBackStack) { - final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(activity); - currentWorker = ExtractorHelper.getStreamInfo(serviceId, url, forceLoad) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(result -> { - isLoading.set(false); - hideMainPlayerOnLoadingNewStream(); - if (result.getAgeLimit() != NO_AGE_LIMIT && !prefs.getBoolean( - getString(R.string.show_age_restricted_content), false)) { - hideAgeRestrictedContent(); - } else { - handleResult(result); - showContent(); - if (addToBackStack) { - if (playQueue == null) { - playQueue = new SinglePlayQueue(result); - } - if (stack.isEmpty() || !stack.peek().getPlayQueue() - .equalStreams(playQueue)) { - stack.push(new StackItem(serviceId, url, title, playQueue)); - } - } - - if (isAutoplayEnabled()) { - openVideoPlayerAutoFullscreen(); - } - } - }, throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_STREAM, - url == null ? "no url" : url, serviceId))); - } - - /*////////////////////////////////////////////////////////////////////////// - // Tabs - //////////////////////////////////////////////////////////////////////////*/ - - private void initTabs() { - if (pageAdapter.getCount() != 0) { - selectedTabTag = pageAdapter.getItemTitle(binding.viewPager.getCurrentItem()); - } - pageAdapter.clearAllItems(); - tabIcons.clear(); - tabContentDescriptions.clear(); - - if (shouldShowComments()) { - pageAdapter.addFragment(CommentsFragment.getInstance(serviceId, url), COMMENTS_TAB_TAG); - tabIcons.add(R.drawable.ic_comment); - tabContentDescriptions.add(R.string.comments_tab_description); - } - - if (showRelatedItems && binding.relatedItemsLayout == null) { - // temp empty fragment. will be updated in handleResult - pageAdapter.addFragment(EmptyFragment.newInstance(false), RELATED_TAB_TAG); - tabIcons.add(R.drawable.ic_art_track); - tabContentDescriptions.add(R.string.related_items_tab_description); - } - - if (showDescription) { - // temp empty fragment. will be updated in handleResult - pageAdapter.addFragment(EmptyFragment.newInstance(false), DESCRIPTION_TAB_TAG); - tabIcons.add(R.drawable.ic_description); - tabContentDescriptions.add(R.string.description_tab_description); - } - - if (pageAdapter.getCount() == 0) { - pageAdapter.addFragment(EmptyFragment.newInstance(true), EMPTY_TAB_TAG); - } - pageAdapter.notifyDataSetUpdate(); - - if (pageAdapter.getCount() >= 2) { - final int position = pageAdapter.getItemPositionByTitle(selectedTabTag); - if (position != -1) { - binding.viewPager.setCurrentItem(position); - } - updateTabIconsAndContentDescriptions(); - } - // the page adapter now contains tabs: show the tab layout - updateTabLayoutVisibility(); - } - - /** - * To be called whenever {@link #pageAdapter} is modified, since that triggers a refresh in - * {@link FragmentVideoDetailBinding#tabLayout} resetting all tab's icons and content - * descriptions. This reads icons from {@link #tabIcons} and content descriptions from - * {@link #tabContentDescriptions}, which are all set in {@link #initTabs()}. - */ - private void updateTabIconsAndContentDescriptions() { - for (int i = 0; i < tabIcons.size(); ++i) { - final TabLayout.Tab tab = binding.tabLayout.getTabAt(i); - if (tab != null) { - tab.setIcon(tabIcons.get(i)); - tab.setContentDescription(tabContentDescriptions.get(i)); - } - } - } - - private void updateTabs(@NonNull final StreamInfo info) { - if (showRelatedItems) { - if (binding.relatedItemsLayout == null) { // phone - pageAdapter.updateItem(RELATED_TAB_TAG, RelatedItemsFragment.getInstance(info)); - } else { // tablet + TV - getChildFragmentManager().beginTransaction() - .replace(R.id.relatedItemsLayout, RelatedItemsFragment.getInstance(info)) - .commitAllowingStateLoss(); - binding.relatedItemsLayout.setVisibility(isFullscreen() ? View.GONE : View.VISIBLE); - } - } - - if (showDescription) { - pageAdapter.updateItem(DESCRIPTION_TAB_TAG, new DescriptionFragment(info)); - } - - binding.viewPager.setVisibility(View.VISIBLE); - // make sure the tab layout is visible - updateTabLayoutVisibility(); - pageAdapter.notifyDataSetUpdate(); - updateTabIconsAndContentDescriptions(); - } - - private boolean shouldShowComments() { - try { - return showComments && NewPipe.getService(serviceId) - .getServiceInfo() - .getMediaCapabilities() - .contains(COMMENTS); - } catch (final ExtractionException e) { - return false; - } - } - - public void updateTabLayoutVisibility() { - - if (binding == null) { - //If binding is null we do not need to and should not do anything with its object(s) - return; - } - - if (pageAdapter.getCount() < 2 || binding.viewPager.getVisibility() != View.VISIBLE) { - // hide tab layout if there is only one tab or if the view pager is also hidden - binding.tabLayout.setVisibility(View.GONE); - } else { - // call `post()` to be sure `viewPager.getHitRect()` - // is up to date and not being currently recomputed - binding.tabLayout.post(() -> { - final var activity = getActivity(); - if (activity != null) { - final Rect pagerHitRect = new Rect(); - binding.viewPager.getHitRect(pagerHitRect); - - final int height = DeviceUtils.getWindowHeight(activity.getWindowManager()); - final int viewPagerVisibleHeight = height - pagerHitRect.top; - // see TabLayout.DEFAULT_HEIGHT, which is equal to 48dp - final float tabLayoutHeight = TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_DIP, 48, getResources().getDisplayMetrics()); - - if (viewPagerVisibleHeight > tabLayoutHeight * 2) { - // no translation at all when viewPagerVisibleHeight > tabLayout.height * 3 - binding.tabLayout.setTranslationY( - Math.max(0, tabLayoutHeight * 3 - viewPagerVisibleHeight)); - binding.tabLayout.setVisibility(View.VISIBLE); - } else { - // view pager is not visible enough - binding.tabLayout.setVisibility(View.GONE); - } - } - }); - } - } - - public void scrollToTop() { - binding.appBarLayout.setExpanded(true, true); - // notify tab layout of scrolling - updateTabLayoutVisibility(); - } - - /*////////////////////////////////////////////////////////////////////////// - // Play Utils - //////////////////////////////////////////////////////////////////////////*/ - - private void toggleFullscreenIfInFullscreenMode() { - // If a user watched video inside fullscreen mode and than chose another player - // return to non-fullscreen mode - if (isPlayerAvailable()) { - player.UIs().getOpt(MainPlayerUi.class).ifPresent(playerUi -> { - if (playerUi.isFullscreen()) { - playerUi.toggleFullscreen(); - } - }); - } - } - - private void openBackgroundPlayer(final boolean append) { - final boolean useExternalAudioPlayer = PreferenceManager - .getDefaultSharedPreferences(activity) - .getBoolean(activity.getString(R.string.use_external_audio_player_key), false); - - toggleFullscreenIfInFullscreenMode(); - - if (isPlayerAvailable()) { - // FIXME Workaround #7427 - player.setRecovery(); - } - - if (useExternalAudioPlayer) { - showExternalAudioPlaybackDialog(); - } else { - openNormalBackgroundPlayer(append); - } - } - - private void openPopupPlayer(final boolean append) { - if (!PermissionHelper.isPopupEnabledElseAsk(activity)) { - return; - } - - // See UI changes while remote playQueue changes - if (!isPlayerAvailable()) { - playerHolder.startService(false, this); - } else { - // FIXME Workaround #7427 - player.setRecovery(); - } - - toggleFullscreenIfInFullscreenMode(); - - final PlayQueue queue = setupPlayQueueForIntent(append); - if (append) { //resumePlayback: false - NavigationHelper.enqueueOnPlayer(activity, queue, PlayerType.POPUP); - } else { - replaceQueueIfUserConfirms(() -> NavigationHelper - .playOnPopupPlayer(activity, queue, true)); - } - } - - /** - * Opens the video player, in fullscreen if needed. In order to open fullscreen, the activity - * is toggled to landscape orientation (which will then cause fullscreen mode). - * - * @param directlyFullscreenIfApplicable whether to open fullscreen if we are not already - * in landscape and screen orientation is locked - */ - public void openVideoPlayer(final boolean directlyFullscreenIfApplicable) { - if (directlyFullscreenIfApplicable - && !DeviceUtils.isLandscape(requireContext()) - && PlayerHelper.globalScreenOrientationLocked(requireContext())) { - // Make sure the bottom sheet turns out expanded. When this code kicks in the bottom - // sheet could not have fully expanded yet, and thus be in the STATE_SETTLING state. - // When the activity is rotated, and its state is saved and then restored, the bottom - // sheet would forget what it was doing, since even if STATE_SETTLING is restored, it - // doesn't tell which state it was settling to, and thus the bottom sheet settles to - // STATE_COLLAPSED. This can be solved by manually setting the state that will be - // restored (i.e. bottomSheetState) to STATE_EXPANDED. - updateBottomSheetState(BottomSheetBehavior.STATE_EXPANDED); - // toggle landscape in order to open directly in fullscreen - onScreenRotationButtonClicked(); - } - - if (PreferenceManager.getDefaultSharedPreferences(activity) - .getBoolean(this.getString(R.string.use_external_video_player_key), false)) { - showExternalVideoPlaybackDialog(); - } else { - replaceQueueIfUserConfirms(this::openMainPlayer); - } - } - - /** - * If the option to start directly fullscreen is enabled, calls - * {@link #openVideoPlayer(boolean)} with {@code directlyFullscreenIfApplicable = true}, so that - * if the user is not already in landscape and he has screen orientation locked the activity - * rotates and fullscreen starts. Otherwise, if the option to start directly fullscreen is - * disabled, calls {@link #openVideoPlayer(boolean)} with {@code directlyFullscreenIfApplicable - * = false}, hence preventing it from going directly fullscreen. - */ - public void openVideoPlayerAutoFullscreen() { - openVideoPlayer(PlayerHelper.isStartMainPlayerFullscreenEnabled(requireContext())); - } - - private void openNormalBackgroundPlayer(final boolean append) { - // See UI changes while remote playQueue changes - if (!isPlayerAvailable()) { - playerHolder.startService(false, this); - } - - final PlayQueue queue = setupPlayQueueForIntent(append); - if (append) { - NavigationHelper.enqueueOnPlayer(activity, queue, PlayerType.AUDIO); - } else { - replaceQueueIfUserConfirms(() -> NavigationHelper - .playOnBackgroundPlayer(activity, queue, true)); - } - } - - private void openMainPlayer() { - if (noPlayerServiceAvailable()) { - playerHolder.startService(autoPlayEnabled, this); - return; - } - if (currentInfo == null) { - return; - } - - final PlayQueue queue = setupPlayQueueForIntent(false); - tryAddVideoPlayerView(); - - final Intent playerIntent = NavigationHelper.getPlayerIntent(requireContext(), - PlayerService.class, queue, true, autoPlayEnabled); - ContextCompat.startForegroundService(activity, playerIntent); - } - - /** - * When the video detail fragment is already showing details for a video and the user opens a - * new one, the video detail fragment changes all of its old data to the new stream, so if there - * is a video player currently open it should be hidden. This method does exactly that. If - * autoplay is enabled, the underlying player is not stopped completely, since it is going to - * be reused in a few milliseconds and the flickering would be annoying. - */ - private void hideMainPlayerOnLoadingNewStream() { - final var root = getRoot(); - if (noPlayerServiceAvailable() || root.isEmpty() || !player.videoPlayerSelected()) { - return; - } - - removeVideoPlayerView(); - if (isAutoplayEnabled()) { - playerService.stopForImmediateReusing(); - root.ifPresent(view -> view.setVisibility(View.GONE)); - } else { - playerHolder.stopService(); - } - } - - private PlayQueue setupPlayQueueForIntent(final boolean append) { - if (append) { - return new SinglePlayQueue(currentInfo); - } - - PlayQueue queue = playQueue; - // Size can be 0 because queue removes bad stream automatically when error occurs - if (queue == null || queue.isEmpty()) { - queue = new SinglePlayQueue(currentInfo); - } - - return queue; - } - - /*////////////////////////////////////////////////////////////////////////// - // Utils - //////////////////////////////////////////////////////////////////////////*/ - - public void setAutoPlay(final boolean autoPlay) { - this.autoPlayEnabled = autoPlay; - } - - private void startOnExternalPlayer(@NonNull final Context context, - @NonNull final StreamInfo info, - @NonNull final Stream selectedStream) { - NavigationHelper.playOnExternalPlayer(context, currentInfo.getName(), - currentInfo.getSubChannelName(), selectedStream); - - final HistoryRecordManager recordManager = new HistoryRecordManager(requireContext()); - disposables.add(recordManager.onViewed(info).onErrorComplete() - .subscribe( - ignored -> { /* successful */ }, - error -> Log.e(TAG, "Register view failure: ", error) - )); - } - - private boolean isExternalPlayerEnabled() { - return PreferenceManager.getDefaultSharedPreferences(requireContext()) - .getBoolean(getString(R.string.use_external_video_player_key), false); - } - - // This method overrides default behaviour when setAutoPlay() is called. - // Don't auto play if the user selected an external player or disabled it in settings - private boolean isAutoplayEnabled() { - return autoPlayEnabled - && !isExternalPlayerEnabled() - && (!isPlayerAvailable() || player.videoPlayerSelected()) - && bottomSheetState != BottomSheetBehavior.STATE_HIDDEN - && PlayerHelper.isAutoplayAllowedByUser(requireContext()); - } - - private void tryAddVideoPlayerView() { - if (isPlayerAvailable() && getView() != null) { - // Setup the surface view height, so that it fits the video correctly; this is done also - // here, and not only in the Handler, to avoid a choppy fullscreen rotation animation. - setHeightThumbnail(); - } - - // do all the null checks in the posted lambda, too, since the player, the binding and the - // view could be set or unset before the lambda gets executed on the next main thread cycle - new Handler(Looper.getMainLooper()).post(() -> { - if (!isPlayerAvailable() || getView() == null) { - return; - } - - // setup the surface view height, so that it fits the video correctly - setHeightThumbnail(); - - player.UIs().getOpt(MainPlayerUi.class).ifPresent(playerUi -> { - // sometimes binding would be null here, even though getView() != null above u.u - if (binding != null) { - // prevent from re-adding a view multiple times - playerUi.removeViewFromParent(); - binding.playerPlaceholder.addView(playerUi.getBinding().getRoot()); - playerUi.setupVideoSurfaceIfNeeded(); - } - }); - }); - } - - private void removeVideoPlayerView() { - makeDefaultHeightForVideoPlaceholder(); - - if (player != null) { - player.UIs().getOpt(VideoPlayerUi.class).ifPresent(VideoPlayerUi::removeViewFromParent); - } - } - - private void makeDefaultHeightForVideoPlaceholder() { - if (getView() == null) { - return; - } - - binding.playerPlaceholder.getLayoutParams().height = FrameLayout.LayoutParams.MATCH_PARENT; - binding.playerPlaceholder.requestLayout(); - } - - private final ViewTreeObserver.OnPreDrawListener preDrawListener = - new ViewTreeObserver.OnPreDrawListener() { - @Override - public boolean onPreDraw() { - final DisplayMetrics metrics = getResources().getDisplayMetrics(); - - if (getView() != null) { - final int height = (DeviceUtils.isInMultiWindow(activity) - ? requireView() - : activity.getWindow().getDecorView()).getHeight(); - setHeightThumbnail(height, metrics); - getView().getViewTreeObserver().removeOnPreDrawListener(preDrawListener); - } - return false; - } - }; - - /** - * Method which controls the size of thumbnail and the size of main player inside - * a layout with thumbnail. It decides what height the player should have in both - * screen orientations. It knows about multiWindow feature - * and about videos with aspectRatio ZOOM (the height for them will be a bit higher, - * {@link #MAX_PLAYER_HEIGHT}) - */ - private void setHeightThumbnail() { - final DisplayMetrics metrics = getResources().getDisplayMetrics(); - final boolean isPortrait = metrics.heightPixels > metrics.widthPixels; - requireView().getViewTreeObserver().removeOnPreDrawListener(preDrawListener); - - if (isFullscreen()) { - final int height = (DeviceUtils.isInMultiWindow(activity) - ? requireView() - : activity.getWindow().getDecorView()).getHeight(); - // Height is zero when the view is not yet displayed like after orientation change - if (height != 0) { - setHeightThumbnail(height, metrics); - } else { - requireView().getViewTreeObserver().addOnPreDrawListener(preDrawListener); - } - } else { - final int height = (int) (isPortrait - ? metrics.widthPixels / (16.0f / 9.0f) - : metrics.heightPixels / 2.0f); - setHeightThumbnail(height, metrics); - } - } - - private void setHeightThumbnail(final int newHeight, final DisplayMetrics metrics) { - binding.detailThumbnailImageView.setLayoutParams( - new FrameLayout.LayoutParams( - RelativeLayout.LayoutParams.MATCH_PARENT, newHeight)); - binding.detailThumbnailImageView.setMinimumHeight(newHeight); - if (isPlayerAvailable()) { - final int maxHeight = (int) (metrics.heightPixels * MAX_PLAYER_HEIGHT); - player.UIs().getOpt(VideoPlayerUi.class).ifPresent(ui -> - ui.getBinding().surfaceView.setHeights(newHeight, - ui.isFullscreen() ? newHeight : maxHeight)); - } - } - - private void showContent() { - binding.detailContentRootHiding.setVisibility(View.VISIBLE); - } - - private void setInitialData(final int newServiceId, - @Nullable final String newUrl, - @NonNull final String newTitle, - @Nullable final PlayQueue newPlayQueue) { - this.serviceId = newServiceId; - this.url = newUrl; - this.title = newTitle; - this.playQueue = newPlayQueue; - } - - private void setErrorImage() { - if (binding == null || activity == null) { - return; - } - - binding.detailThumbnailImageView.setImageDrawable( - AppCompatResources.getDrawable(requireContext(), R.drawable.not_available_monkey)); - animate(binding.detailThumbnailImageView, false, 0, AnimationType.ALPHA, - 0, () -> animate(binding.detailThumbnailImageView, true, 500)); - } - - @Override - public void handleError() { - super.handleError(); - setErrorImage(); - - if (binding.relatedItemsLayout != null) { // hide related streams for tablets - binding.relatedItemsLayout.setVisibility(View.INVISIBLE); - } - - // hide comments / related streams / description tabs - binding.viewPager.setVisibility(View.GONE); - binding.tabLayout.setVisibility(View.GONE); - } - - private void hideAgeRestrictedContent() { - showTextError(getString(R.string.restricted_video, - getString(R.string.show_age_restricted_content_title))); - } - - private void setupBroadcastReceiver() { - broadcastReceiver = new BroadcastReceiver() { - @Override - public void onReceive(final Context context, final Intent intent) { - switch (intent.getAction()) { - case ACTION_SHOW_MAIN_PLAYER: - bottomSheetBehavior.setState(BottomSheetBehavior.STATE_EXPANDED); - break; - case ACTION_HIDE_MAIN_PLAYER: - bottomSheetBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - break; - case ACTION_PLAYER_STARTED: - // If the state is not hidden we don't need to show the mini player - if (bottomSheetBehavior.getState() == BottomSheetBehavior.STATE_HIDDEN) { - bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED); - } - // Rebound to the service if it was closed via notification or mini player - if (!playerHolder.isBound()) { - playerHolder.startService( - false, VideoDetailFragment.this); - } - break; - } - } - }; - final IntentFilter intentFilter = new IntentFilter(); - intentFilter.addAction(ACTION_SHOW_MAIN_PLAYER); - intentFilter.addAction(ACTION_HIDE_MAIN_PLAYER); - intentFilter.addAction(ACTION_PLAYER_STARTED); - activity.registerReceiver(broadcastReceiver, intentFilter); - } - - - /*////////////////////////////////////////////////////////////////////////// - // Orientation listener - //////////////////////////////////////////////////////////////////////////*/ - - private void restoreDefaultOrientation() { - if (isPlayerAvailable() && player.videoPlayerSelected()) { - toggleFullscreenIfInFullscreenMode(); - } - - // This will show systemUI and pause the player. - // User can tap on Play button and video will be in fullscreen mode again - // Note for tablet: trying to avoid orientation changes since it's not easy - // to physically rotate the tablet every time - if (activity != null && !DeviceUtils.isTablet(activity)) { - activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED); - } - } - - /*////////////////////////////////////////////////////////////////////////// - // Contract - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void showLoading() { - - super.showLoading(); - - //if data is already cached, transition from VISIBLE -> INVISIBLE -> VISIBLE is not required - if (!ExtractorHelper.isCached(serviceId, url, InfoCache.Type.STREAM)) { - binding.detailContentRootHiding.setVisibility(View.INVISIBLE); - } - - animate(binding.detailThumbnailPlayButton, false, 50); - animate(binding.detailDurationView, false, 100); - binding.detailPositionView.setVisibility(View.GONE); - binding.positionView.setVisibility(View.GONE); - - binding.detailVideoTitleView.setText(title); - binding.detailVideoTitleView.setMaxLines(1); - animate(binding.detailVideoTitleView, true, 0); - - binding.detailToggleSecondaryControlsView.setVisibility(View.GONE); - binding.detailTitleRootLayout.setClickable(false); - binding.detailSecondaryControlPanel.setVisibility(View.GONE); - - if (binding.relatedItemsLayout != null) { - if (showRelatedItems) { - binding.relatedItemsLayout.setVisibility( - isFullscreen() ? View.GONE : View.INVISIBLE); - } else { - binding.relatedItemsLayout.setVisibility(View.GONE); - } - } - - CoilUtils.dispose(binding.detailThumbnailImageView); - CoilUtils.dispose(binding.detailSubChannelThumbnailView); - CoilUtils.dispose(binding.overlayThumbnail); - CoilUtils.dispose(binding.detailUploaderThumbnailView); - - binding.detailThumbnailImageView.setImageBitmap(null); - binding.detailSubChannelThumbnailView.setImageBitmap(null); - } - - @Override - public void handleResult(@NonNull final StreamInfo info) { - super.handleResult(info); - - currentInfo = info; - setInitialData(info.getServiceId(), info.getOriginalUrl(), info.getName(), playQueue); - - updateTabs(info); - - animate(binding.detailThumbnailPlayButton, true, 200); - binding.detailVideoTitleView.setText(title); - - binding.detailSubChannelThumbnailView.setVisibility(View.GONE); - - if (!isEmpty(info.getSubChannelName())) { - displayBothUploaderAndSubChannel(info); - } else { - displayUploaderAsSubChannel(info); - } - - if (info.getViewCount() >= 0) { - if (info.getStreamType().equals(StreamType.AUDIO_LIVE_STREAM)) { - binding.detailViewCountView.setText(Localization.listeningCount(activity, - info.getViewCount())); - } else if (info.getStreamType().equals(StreamType.LIVE_STREAM)) { - binding.detailViewCountView.setText(Localization - .localizeWatchingCount(activity, info.getViewCount())); - } else { - binding.detailViewCountView.setText(Localization - .localizeViewCount(activity, info.getViewCount())); - } - binding.detailViewCountView.setVisibility(View.VISIBLE); - } else { - binding.detailViewCountView.setVisibility(View.GONE); - } - - if (info.getDislikeCount() == -1 && info.getLikeCount() == -1) { - binding.detailThumbsDownImgView.setVisibility(View.VISIBLE); - binding.detailThumbsUpImgView.setVisibility(View.VISIBLE); - binding.detailThumbsUpCountView.setVisibility(View.GONE); - binding.detailThumbsDownCountView.setVisibility(View.GONE); - - binding.detailThumbsDisabledView.setVisibility(View.VISIBLE); - } else { - if (info.getDislikeCount() >= 0) { - binding.detailThumbsDownCountView.setText(Localization - .shortCount(activity, info.getDislikeCount())); - binding.detailThumbsDownCountView.setVisibility(View.VISIBLE); - binding.detailThumbsDownImgView.setVisibility(View.VISIBLE); - } else { - binding.detailThumbsDownCountView.setVisibility(View.GONE); - binding.detailThumbsDownImgView.setVisibility(View.GONE); - } - - if (info.getLikeCount() >= 0) { - binding.detailThumbsUpCountView.setText(Localization.shortCount(activity, - info.getLikeCount())); - binding.detailThumbsUpCountView.setVisibility(View.VISIBLE); - binding.detailThumbsUpImgView.setVisibility(View.VISIBLE); - } else { - binding.detailThumbsUpCountView.setVisibility(View.GONE); - binding.detailThumbsUpImgView.setVisibility(View.GONE); - } - binding.detailThumbsDisabledView.setVisibility(View.GONE); - } - - if (info.getDuration() > 0) { - binding.detailDurationView.setText(Localization.getDurationString(info.getDuration())); - binding.detailDurationView.setBackgroundColor( - ContextCompat.getColor(activity, R.color.duration_background_color)); - animate(binding.detailDurationView, true, 100); - } else if (info.getStreamType() == StreamType.LIVE_STREAM) { - binding.detailDurationView.setText(R.string.duration_live); - binding.detailDurationView.setBackgroundColor( - ContextCompat.getColor(activity, R.color.live_duration_background_color)); - animate(binding.detailDurationView, true, 100); - } else { - binding.detailDurationView.setVisibility(View.GONE); - } - - binding.detailTitleRootLayout.setClickable(true); - binding.detailToggleSecondaryControlsView.setRotation(0); - binding.detailToggleSecondaryControlsView.setVisibility(View.VISIBLE); - binding.detailSecondaryControlPanel.setVisibility(View.GONE); - - checkUpdateProgressInfo(info); - CoilHelper.INSTANCE.loadDetailsThumbnail(binding.detailThumbnailImageView, - info.getThumbnails()); - showMetaInfoInTextView(info.getMetaInfo(), binding.detailMetaInfoTextView, - binding.detailMetaInfoSeparator, disposables); - - if (!isPlayerAvailable() || player.isStopped()) { - updateOverlayData(info.getName(), info.getUploaderName(), info.getThumbnails()); - } - - if (!info.getErrors().isEmpty()) { - // Bandcamp fan pages are not yet supported and thus a ContentNotAvailableException is - // thrown. This is not an error and thus should not be shown to the user. - for (final Throwable throwable : info.getErrors()) { - if (throwable instanceof ContentNotSupportedException - && "Fan pages are not supported".equals(throwable.getMessage())) { - info.getErrors().remove(throwable); - } - } - - if (!info.getErrors().isEmpty()) { - showSnackBarError(new ErrorInfo(info.getErrors(), - UserAction.REQUESTED_STREAM, info.getUrl(), info)); - } - } - - binding.detailControlsDownload.setVisibility( - StreamTypeUtil.isLiveStream(info.getStreamType()) ? View.GONE : View.VISIBLE); - binding.detailControlsBackground.setVisibility( - info.getAudioStreams().isEmpty() && info.getVideoStreams().isEmpty() - ? View.GONE : View.VISIBLE); - - final boolean noVideoStreams = - info.getVideoStreams().isEmpty() && info.getVideoOnlyStreams().isEmpty(); - binding.detailControlsPopup.setVisibility(noVideoStreams ? View.GONE : View.VISIBLE); - binding.detailThumbnailPlayButton.setImageResource( - noVideoStreams ? R.drawable.ic_headset_shadow : R.drawable.ic_play_arrow_shadow); - } - - private void displayUploaderAsSubChannel(final StreamInfo info) { - binding.detailSubChannelTextView.setText(info.getUploaderName()); - binding.detailSubChannelTextView.setVisibility(View.VISIBLE); - binding.detailSubChannelTextView.setSelected(true); - - if (info.getUploaderSubscriberCount() > -1) { - binding.detailUploaderTextView.setText( - Localization.shortSubscriberCount(activity, info.getUploaderSubscriberCount())); - binding.detailUploaderTextView.setVisibility(View.VISIBLE); - } else { - binding.detailUploaderTextView.setVisibility(View.GONE); - } - - CoilHelper.INSTANCE.loadAvatar(binding.detailSubChannelThumbnailView, - info.getUploaderAvatars()); - binding.detailSubChannelThumbnailView.setVisibility(View.VISIBLE); - binding.detailUploaderThumbnailView.setVisibility(View.GONE); - } - - private void displayBothUploaderAndSubChannel(final StreamInfo info) { - binding.detailSubChannelTextView.setText(info.getSubChannelName()); - binding.detailSubChannelTextView.setVisibility(View.VISIBLE); - binding.detailSubChannelTextView.setSelected(true); - - final StringBuilder subText = new StringBuilder(); - if (!isEmpty(info.getUploaderName())) { - subText.append( - String.format(getString(R.string.video_detail_by), info.getUploaderName())); - } - if (info.getUploaderSubscriberCount() > -1) { - if (subText.length() > 0) { - subText.append(Localization.DOT_SEPARATOR); - } - subText.append( - Localization.shortSubscriberCount(activity, info.getUploaderSubscriberCount())); - } - - if (subText.length() > 0) { - binding.detailUploaderTextView.setText(subText); - binding.detailUploaderTextView.setVisibility(View.VISIBLE); - binding.detailUploaderTextView.setSelected(true); - } else { - binding.detailUploaderTextView.setVisibility(View.GONE); - } - - CoilHelper.INSTANCE.loadAvatar(binding.detailSubChannelThumbnailView, - info.getSubChannelAvatars()); - binding.detailSubChannelThumbnailView.setVisibility(View.VISIBLE); - CoilHelper.INSTANCE.loadAvatar(binding.detailUploaderThumbnailView, - info.getUploaderAvatars()); - binding.detailUploaderThumbnailView.setVisibility(View.VISIBLE); - } - - public void openDownloadDialog() { - if (currentInfo == null) { - return; - } - - try { - final DownloadDialog downloadDialog = new DownloadDialog(activity, currentInfo); - downloadDialog.show(activity.getSupportFragmentManager(), "downloadDialog"); - } catch (final Exception e) { - ErrorUtil.showSnackbar(activity, new ErrorInfo(e, UserAction.DOWNLOAD_OPEN_DIALOG, - "Showing download dialog", currentInfo)); - } - } - - /*////////////////////////////////////////////////////////////////////////// - // Stream Results - //////////////////////////////////////////////////////////////////////////*/ - - private void checkUpdateProgressInfo(@NonNull final StreamInfo info) { - if (positionSubscriber != null) { - positionSubscriber.dispose(); - } - if (!getResumePlaybackEnabled(activity)) { - binding.positionView.setVisibility(View.GONE); - binding.detailPositionView.setVisibility(View.GONE); - return; - } - final HistoryRecordManager recordManager = new HistoryRecordManager(requireContext()); - positionSubscriber = recordManager.loadStreamState(info) - .subscribeOn(Schedulers.io()) - .onErrorComplete() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(state -> { - updatePlaybackProgress( - state.getProgressMillis(), info.getDuration() * 1000); - }, e -> { - // impossible since the onErrorComplete() - }, () -> { - binding.positionView.setVisibility(View.GONE); - binding.detailPositionView.setVisibility(View.GONE); - }); - } - - private void updatePlaybackProgress(final long progress, final long duration) { - if (!getResumePlaybackEnabled(activity)) { - return; - } - final int progressSeconds = (int) TimeUnit.MILLISECONDS.toSeconds(progress); - final int durationSeconds = (int) TimeUnit.MILLISECONDS.toSeconds(duration); - // If the old and the new progress values have a big difference then use animation. - // Otherwise don't because it affects CPU - final int progressDifference = Math.abs(binding.positionView.getProgress() - - progressSeconds); - binding.positionView.setMax(durationSeconds); - if (progressDifference > 2) { - binding.positionView.setProgressAnimated(progressSeconds); - } else { - binding.positionView.setProgress(progressSeconds); - } - final String position = Localization.getDurationString(progressSeconds); - if (position != binding.detailPositionView.getText()) { - binding.detailPositionView.setText(position); - } - if (binding.positionView.getVisibility() != View.VISIBLE) { - animate(binding.positionView, true, 100); - animate(binding.detailPositionView, true, 100); - } - } - - /*////////////////////////////////////////////////////////////////////////// - // Player event listener - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onViewCreated() { - tryAddVideoPlayerView(); - } - - @Override - public void onQueueUpdate(final PlayQueue queue) { - playQueue = queue; - if (DEBUG) { - Log.d(TAG, "onQueueUpdate() called with: serviceId = [" - + serviceId + "], url = [" + url + "], name = [" - + title + "], playQueue = [" + playQueue + "]"); - } - - // Register broadcast receiver to listen to playQueue changes - // and hide the overlayPlayQueueButton when the playQueue is empty / destroyed. - if (playQueue != null && playQueue.getBroadcastReceiver() != null) { - playQueue.getBroadcastReceiver().subscribe( - event -> updateOverlayPlayQueueButtonVisibility() - ); - } - - // This should be the only place where we push data to stack. - // It will allow to have live instance of PlayQueue with actual information about - // deleted/added items inside Channel/Playlist queue and makes possible to have - // a history of played items - @Nullable final StackItem stackPeek = stack.peek(); - if (stackPeek != null && !stackPeek.getPlayQueue().equalStreams(queue)) { - @Nullable final PlayQueueItem playQueueItem = queue.getItem(); - if (playQueueItem != null) { - stack.push(new StackItem(playQueueItem.getServiceId(), playQueueItem.getUrl(), - playQueueItem.getTitle(), queue)); - return; - } // else continue below - } - - @Nullable final StackItem stackWithQueue = findQueueInStack(queue); - if (stackWithQueue != null) { - // On every MainPlayer service's destroy() playQueue gets disposed and - // no longer able to track progress. That's why we update our cached disposed - // queue with the new one that is active and have the same history. - // Without that the cached playQueue will have an old recovery position - stackWithQueue.setPlayQueue(queue); - } - } - - @Override - public void onPlaybackUpdate(final int state, - final int repeatMode, - final boolean shuffled, - final PlaybackParameters parameters) { - setOverlayPlayPauseImage(player != null && player.isPlaying()); - - if (state == Player.STATE_PLAYING) { - if (binding.positionView.getAlpha() != 1.0f - && player.getPlayQueue() != null - && player.getPlayQueue().getItem() != null - && player.getPlayQueue().getItem().getUrl().equals(url)) { - animate(binding.positionView, true, 100); - animate(binding.detailPositionView, true, 100); - } - } - } - - @Override - public void onProgressUpdate(final int currentProgress, - final int duration, - final int bufferPercent) { - // Progress updates every second even if media is paused. It's useless until playing - if (!player.isPlaying() || playQueue == null) { - return; - } - - if (player.getPlayQueue().getItem().getUrl().equals(url)) { - updatePlaybackProgress(currentProgress, duration); - } - } - - @Override - public void onMetadataUpdate(final StreamInfo info, final PlayQueue queue) { - final StackItem item = findQueueInStack(queue); - if (item != null) { - // When PlayQueue can have multiple streams (PlaylistPlayQueue or ChannelPlayQueue) - // every new played stream gives new title and url. - // StackItem contains information about first played stream. Let's update it here - item.setTitle(info.getName()); - item.setUrl(info.getUrl()); - } - // They are not equal when user watches something in popup while browsing in fragment and - // then changes screen orientation. In that case the fragment will set itself as - // a service listener and will receive initial call to onMetadataUpdate() - if (!queue.equalStreams(playQueue)) { - return; - } - - updateOverlayData(info.getName(), info.getUploaderName(), info.getThumbnails()); - if (currentInfo != null && info.getUrl().equals(currentInfo.getUrl())) { - return; - } - - currentInfo = info; - setInitialData(info.getServiceId(), info.getUrl(), info.getName(), queue); - setAutoPlay(false); - // Delay execution just because it freezes the main thread, and while playing - // next/previous video you see visual glitches - // (when non-vertical video goes after vertical video) - prepareAndHandleInfoIfNeededAfterDelay(info, true, 200); - } - - @Override - public void onPlayerError(final PlaybackException error, final boolean isCatchableException) { - if (!isCatchableException) { - // Properly exit from fullscreen - toggleFullscreenIfInFullscreenMode(); - hideMainPlayerOnLoadingNewStream(); - } - } - - @Override - public void onServiceStopped() { - // the binding could be null at this point, if the app is finishing - if (binding != null) { - setOverlayPlayPauseImage(false); - if (currentInfo != null) { - updateOverlayData(currentInfo.getName(), - currentInfo.getUploaderName(), - currentInfo.getThumbnails()); - } - updateOverlayPlayQueueButtonVisibility(); - } - } - - @Override - public void onFullscreenStateChanged(final boolean fullscreen) { - setupBrightness(); - if (!isPlayerAndPlayerServiceAvailable() - || player.UIs().getOpt(MainPlayerUi.class).isEmpty() - || getRoot().map(View::getParent).isEmpty()) { - return; - } - - if (fullscreen) { - hideSystemUiIfNeeded(); - binding.overlayPlayPauseButton.requestFocus(); - } else { - showSystemUi(); - } - - if (binding.relatedItemsLayout != null) { - binding.relatedItemsLayout.setVisibility(fullscreen ? View.GONE : View.VISIBLE); - } - scrollToTop(); - - tryAddVideoPlayerView(); - } - - @Override - public void onScreenRotationButtonClicked() { - // In tablet user experience will be better if screen will not be rotated - // from landscape to portrait every time. - // Just turn on fullscreen mode in landscape orientation - // or portrait & unlocked global orientation - final boolean isLandscape = DeviceUtils.isLandscape(requireContext()); - if (DeviceUtils.isTablet(activity) - && (!globalScreenOrientationLocked(activity) || isLandscape)) { - player.UIs().getOpt(MainPlayerUi.class).ifPresent(MainPlayerUi::toggleFullscreen); - return; - } - - final int newOrientation = isLandscape - ? ActivityInfo.SCREEN_ORIENTATION_PORTRAIT - : ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE; - - activity.setRequestedOrientation(newOrientation); - } - - /* - * Will scroll down to description view after long click on moreOptionsButton - * */ - @Override - public void onMoreOptionsLongClicked() { - final CoordinatorLayout.LayoutParams params = - (CoordinatorLayout.LayoutParams) binding.appBarLayout.getLayoutParams(); - final AppBarLayout.Behavior behavior = (AppBarLayout.Behavior) params.getBehavior(); - final ValueAnimator valueAnimator = ValueAnimator - .ofInt(0, -binding.playerPlaceholder.getHeight()); - valueAnimator.setInterpolator(new DecelerateInterpolator()); - valueAnimator.addUpdateListener(animation -> { - behavior.setTopAndBottomOffset((int) animation.getAnimatedValue()); - binding.appBarLayout.requestLayout(); - }); - valueAnimator.setInterpolator(new DecelerateInterpolator()); - valueAnimator.setDuration(500); - valueAnimator.start(); - } - - /*////////////////////////////////////////////////////////////////////////// - // Player related utils - //////////////////////////////////////////////////////////////////////////*/ - - private void showSystemUi() { - if (DEBUG) { - Log.d(TAG, "showSystemUi() called"); - } - - if (activity == null) { - return; - } - - // Prevent jumping of the player on devices with cutout - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - activity.getWindow().getAttributes().layoutInDisplayCutoutMode = - WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT; - } - activity.getWindow().getDecorView().setSystemUiVisibility(0); - activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); - activity.getWindow().setStatusBarColor(ThemeHelper.resolveColorFromAttr( - requireContext(), android.R.attr.colorPrimary)); - } - - private void hideSystemUi() { - if (DEBUG) { - Log.d(TAG, "hideSystemUi() called"); - } - - if (activity == null) { - return; - } - - // Prevent jumping of the player on devices with cutout - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - activity.getWindow().getAttributes().layoutInDisplayCutoutMode = - WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES; - } - int visibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE - | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN - | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION - | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION - | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY; - - // In multiWindow mode status bar is not transparent for devices with cutout - // if I include this flag. So without it is better in this case - final boolean isInMultiWindow = DeviceUtils.isInMultiWindow(activity); - if (!isInMultiWindow) { - visibility |= View.SYSTEM_UI_FLAG_FULLSCREEN; - } - activity.getWindow().getDecorView().setSystemUiVisibility(visibility); - - if (isInMultiWindow || isFullscreen()) { - activity.getWindow().setStatusBarColor(Color.TRANSPARENT); - activity.getWindow().setNavigationBarColor(Color.TRANSPARENT); - } - activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); - } - - // Listener implementation - @Override - public void hideSystemUiIfNeeded() { - if (isFullscreen() - && bottomSheetBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED) { - hideSystemUi(); - } - } - - private boolean isFullscreen() { - return isPlayerAvailable() && player.UIs().getOpt(VideoPlayerUi.class) - .map(VideoPlayerUi::isFullscreen).orElse(false); - } - - private boolean playerIsNotStopped() { - return isPlayerAvailable() && !player.isStopped(); - } - - private void restoreDefaultBrightness() { - final WindowManager.LayoutParams lp = activity.getWindow().getAttributes(); - if (lp.screenBrightness == -1) { - return; - } - - // Restore the old brightness when fragment.onPause() called or - // when a player is in portrait - lp.screenBrightness = -1; - activity.getWindow().setAttributes(lp); - } - - private void setupBrightness() { - if (activity == null) { - return; - } - - final WindowManager.LayoutParams lp = activity.getWindow().getAttributes(); - if (!isFullscreen() || bottomSheetState != BottomSheetBehavior.STATE_EXPANDED) { - // Apply system brightness when the player is not in fullscreen - restoreDefaultBrightness(); - } else { - // Do not restore if user has disabled brightness gesture - if (!PlayerHelper.getActionForRightGestureSide(activity) - .equals(getString(R.string.brightness_control_key)) - && !PlayerHelper.getActionForLeftGestureSide(activity) - .equals(getString(R.string.brightness_control_key))) { - return; - } - // Restore already saved brightness level - final float brightnessLevel = PlayerHelper.getScreenBrightness(activity); - if (brightnessLevel == lp.screenBrightness) { - return; - } - lp.screenBrightness = brightnessLevel; - activity.getWindow().setAttributes(lp); - } - } - - /** - * Make changes to the UI to accommodate for better usability on bigger screens such as TVs - * or in Android's desktop mode (DeX etc). - */ - private void accommodateForTvAndDesktopMode() { - if (DeviceUtils.isTv(getContext())) { - // remove ripple effects from detail controls - final int transparent = ContextCompat.getColor(requireContext(), - R.color.transparent_background_color); - binding.detailControlsPlaylistAppend.setBackgroundColor(transparent); - binding.detailControlsBackground.setBackgroundColor(transparent); - binding.detailControlsPopup.setBackgroundColor(transparent); - binding.detailControlsDownload.setBackgroundColor(transparent); - binding.detailControlsShare.setBackgroundColor(transparent); - binding.detailControlsOpenInBrowser.setBackgroundColor(transparent); - binding.detailControlsPlayWithKodi.setBackgroundColor(transparent); - } - if (DeviceUtils.isDesktopMode(getContext())) { - // Remove the "hover" overlay (since it is visible on all mouse events and interferes - // with the video content being played) - binding.detailThumbnailRootLayout.setForeground(null); - } - } - - private void checkLandscape() { - if ((!player.isPlaying() && player.getPlayQueue() != playQueue) - || player.getPlayQueue() == null) { - setAutoPlay(true); - } - - player.UIs().getOpt(MainPlayerUi.class).ifPresent(MainPlayerUi::checkLandscape); - // Let's give a user time to look at video information page if video is not playing - if (globalScreenOrientationLocked(activity) && !player.isPlaying()) { - player.play(); - } - } - - /* - * Means that the player fragment was swiped away via BottomSheetLayout - * and is empty but ready for any new actions. See cleanUp() - * */ - private boolean wasCleared() { - return url == null; - } - - @Nullable - private StackItem findQueueInStack(final PlayQueue queue) { - StackItem item = null; - final Iterator iterator = stack.descendingIterator(); - while (iterator.hasNext()) { - final StackItem next = iterator.next(); - if (next.getPlayQueue().equalStreams(queue)) { - item = next; - break; - } - } - return item; - } - - private void replaceQueueIfUserConfirms(final Runnable onAllow) { - @Nullable final PlayQueue activeQueue = isPlayerAvailable() ? player.getPlayQueue() : null; - - // Player will have STATE_IDLE when a user pressed back button - if (isClearingQueueConfirmationRequired(activity) - && playerIsNotStopped() - && activeQueue != null - && !activeQueue.equalStreams(playQueue)) { - showClearingQueueConfirmation(onAllow); - } else { - onAllow.run(); - } - } - - private void showClearingQueueConfirmation(final Runnable onAllow) { - new AlertDialog.Builder(activity) - .setTitle(R.string.clear_queue_confirmation_description) - .setNegativeButton(R.string.cancel, null) - .setPositiveButton(R.string.ok, (dialog, which) -> { - onAllow.run(); - dialog.dismiss(); - }) - .show(); - } - - private void showExternalVideoPlaybackDialog() { - if (currentInfo == null) { - return; - } - - final AlertDialog.Builder builder = new AlertDialog.Builder(activity); - builder.setTitle(R.string.select_quality_external_players); - builder.setNeutralButton(R.string.open_in_browser, (dialog, i) -> - ShareUtils.openUrlInBrowser(requireActivity(), url)); - - final List videoStreamsForExternalPlayers = - ListHelper.getSortedStreamVideosList( - activity, - getUrlAndNonTorrentStreams(currentInfo.getVideoStreams()), - getUrlAndNonTorrentStreams(currentInfo.getVideoOnlyStreams()), - false, - false - ); - - if (videoStreamsForExternalPlayers.isEmpty()) { - builder.setMessage(R.string.no_video_streams_available_for_external_players); - builder.setPositiveButton(R.string.ok, null); - - } else { - final int selectedVideoStreamIndexForExternalPlayers = - ListHelper.getDefaultResolutionIndex(activity, videoStreamsForExternalPlayers); - final CharSequence[] resolutions = videoStreamsForExternalPlayers.stream() - .map(VideoStream::getResolution).toArray(CharSequence[]::new); - - builder.setSingleChoiceItems(resolutions, selectedVideoStreamIndexForExternalPlayers, - null); - builder.setNegativeButton(R.string.cancel, null); - builder.setPositiveButton(R.string.ok, (dialog, i) -> { - final int index = ((AlertDialog) dialog).getListView().getCheckedItemPosition(); - // We don't have to manage the index validity because if there is no stream - // available for external players, this code will be not executed and if there is - // no stream which matches the default resolution, 0 is returned by - // ListHelper.getDefaultResolutionIndex. - // The index cannot be outside the bounds of the list as its always between 0 and - // the list size - 1, . - startOnExternalPlayer(activity, currentInfo, - videoStreamsForExternalPlayers.get(index)); - }); - } - builder.show(); - } - - private void showExternalAudioPlaybackDialog() { - if (currentInfo == null) { - return; - } - - final List audioStreams = getUrlAndNonTorrentStreams( - currentInfo.getAudioStreams()); - final List audioTracks = - ListHelper.getFilteredAudioStreams(activity, audioStreams); - - if (audioTracks.isEmpty()) { - Toast.makeText(activity, R.string.no_audio_streams_available_for_external_players, - Toast.LENGTH_SHORT).show(); - } else if (audioTracks.size() == 1) { - startOnExternalPlayer(activity, currentInfo, audioTracks.get(0)); - } else { - final int selectedAudioStream = - ListHelper.getDefaultAudioFormat(activity, audioTracks); - final CharSequence[] trackNames = audioTracks.stream() - .map(audioStream -> Localization.audioTrackName(activity, audioStream)) - .toArray(CharSequence[]::new); - - new AlertDialog.Builder(activity) - .setTitle(R.string.select_audio_track_external_players) - .setNeutralButton(R.string.open_in_browser, (dialog, i) -> - ShareUtils.openUrlInBrowser(requireActivity(), url)) - .setSingleChoiceItems(trackNames, selectedAudioStream, null) - .setNegativeButton(R.string.cancel, null) - .setPositiveButton(R.string.ok, (dialog, i) -> { - final int index = ((AlertDialog) dialog).getListView() - .getCheckedItemPosition(); - startOnExternalPlayer(activity, currentInfo, audioTracks.get(index)); - }) - .show(); - } - } - - /* - * Remove unneeded information while waiting for a next task - * */ - private void cleanUp() { - // New beginning - stack.clear(); - if (currentWorker != null) { - currentWorker.dispose(); - } - playerHolder.stopService(); - setInitialData(0, null, "", null); - currentInfo = null; - updateOverlayData(null, null, List.of()); - } - - /*////////////////////////////////////////////////////////////////////////// - // Bottom mini player - //////////////////////////////////////////////////////////////////////////*/ - - /** - * That's for Android TV support. Move focus from main fragment to the player or back - * based on what is currently selected - * - * @param toMain if true than the main fragment will be focused or the player otherwise - */ - private void moveFocusToMainFragment(final boolean toMain) { - setupBrightness(); - final ViewGroup mainFragment = requireActivity().findViewById(R.id.fragment_holder); - // Hamburger button steels a focus even under bottomSheet - final Toolbar toolbar = requireActivity().findViewById(R.id.toolbar); - final int afterDescendants = ViewGroup.FOCUS_AFTER_DESCENDANTS; - final int blockDescendants = ViewGroup.FOCUS_BLOCK_DESCENDANTS; - if (toMain) { - mainFragment.setDescendantFocusability(afterDescendants); - toolbar.setDescendantFocusability(afterDescendants); - ((ViewGroup) requireView()).setDescendantFocusability(blockDescendants); - // Only focus the mainFragment if the mainFragment (e.g. search-results) - // or the toolbar (e.g. Textfield for search) don't have focus. - // This was done to fix problems with the keyboard input, see also #7490 - if (!mainFragment.hasFocus() && !toolbar.hasFocus()) { - mainFragment.requestFocus(); - } - } else { - mainFragment.setDescendantFocusability(blockDescendants); - toolbar.setDescendantFocusability(blockDescendants); - ((ViewGroup) requireView()).setDescendantFocusability(afterDescendants); - // Only focus the player if it not already has focus - if (!binding.getRoot().hasFocus()) { - binding.detailThumbnailRootLayout.requestFocus(); - } - } - } - - /** - * When the mini player exists the view underneath it is not touchable. - * Bottom padding should be equal to the mini player's height in this case - * - * @param showMore whether main fragment should be expanded or not - */ - private void manageSpaceAtTheBottom(final boolean showMore) { - final int peekHeight = getResources().getDimensionPixelSize(R.dimen.mini_player_height); - final ViewGroup holder = requireActivity().findViewById(R.id.fragment_holder); - final int newBottomPadding; - if (showMore) { - newBottomPadding = 0; - } else { - newBottomPadding = peekHeight; - } - if (holder.getPaddingBottom() == newBottomPadding) { - return; - } - holder.setPadding(holder.getPaddingLeft(), - holder.getPaddingTop(), - holder.getPaddingRight(), - newBottomPadding); - } - - private void setupBottomPlayer() { - final CoordinatorLayout.LayoutParams params = - (CoordinatorLayout.LayoutParams) binding.appBarLayout.getLayoutParams(); - final AppBarLayout.Behavior behavior = (AppBarLayout.Behavior) params.getBehavior(); - - final FrameLayout bottomSheetLayout = activity.findViewById(R.id.fragment_player_holder); - bottomSheetBehavior = BottomSheetBehavior.from(bottomSheetLayout); - bottomSheetBehavior.setState(lastStableBottomSheetState); - updateBottomSheetState(lastStableBottomSheetState); - - final int peekHeight = getResources().getDimensionPixelSize(R.dimen.mini_player_height); - if (bottomSheetState != BottomSheetBehavior.STATE_HIDDEN) { - manageSpaceAtTheBottom(false); - bottomSheetBehavior.setPeekHeight(peekHeight); - if (bottomSheetState == BottomSheetBehavior.STATE_COLLAPSED) { - binding.overlayLayout.setAlpha(MAX_OVERLAY_ALPHA); - } else if (bottomSheetState == BottomSheetBehavior.STATE_EXPANDED) { - binding.overlayLayout.setAlpha(0); - setOverlayElementsClickable(false); - } - } - - bottomSheetCallback = new BottomSheetBehavior.BottomSheetCallback() { - @Override - public void onStateChanged(@NonNull final View bottomSheet, final int newState) { - updateBottomSheetState(newState); - - switch (newState) { - case BottomSheetBehavior.STATE_HIDDEN: - moveFocusToMainFragment(true); - manageSpaceAtTheBottom(true); - - bottomSheetBehavior.setPeekHeight(0); - cleanUp(); - break; - case BottomSheetBehavior.STATE_EXPANDED: - moveFocusToMainFragment(false); - manageSpaceAtTheBottom(false); - - bottomSheetBehavior.setPeekHeight(peekHeight); - // Disable click because overlay buttons located on top of buttons - // from the player - setOverlayElementsClickable(false); - hideSystemUiIfNeeded(); - // Conditions when the player should be expanded to fullscreen - if (DeviceUtils.isLandscape(requireContext()) - && isPlayerAvailable() - && player.isPlaying() - && !isFullscreen() - && !DeviceUtils.isTablet(activity)) { - player.UIs().getOpt(MainPlayerUi.class) - .ifPresent(MainPlayerUi::toggleFullscreen); - } - setOverlayLook(binding.appBarLayout, behavior, 1); - break; - case BottomSheetBehavior.STATE_COLLAPSED: - moveFocusToMainFragment(true); - manageSpaceAtTheBottom(false); - - bottomSheetBehavior.setPeekHeight(peekHeight); - - // Re-enable clicks - setOverlayElementsClickable(true); - if (isPlayerAvailable()) { - player.UIs().getOpt(MainPlayerUi.class) - .ifPresent(MainPlayerUi::closeItemsList); - } - setOverlayLook(binding.appBarLayout, behavior, 0); - break; - case BottomSheetBehavior.STATE_DRAGGING: - case BottomSheetBehavior.STATE_SETTLING: - if (isFullscreen()) { - showSystemUi(); - } - if (isPlayerAvailable()) { - player.UIs().getOpt(MainPlayerUi.class).ifPresent(ui -> { - if (ui.isControlsVisible()) { - ui.hideControls(0, 0); - } - }); - } - break; - case BottomSheetBehavior.STATE_HALF_EXPANDED: - break; - } - } - - @Override - public void onSlide(@NonNull final View bottomSheet, final float slideOffset) { - setOverlayLook(binding.appBarLayout, behavior, slideOffset); - } - }; - - bottomSheetBehavior.addBottomSheetCallback(bottomSheetCallback); - - // User opened a new page and the player will hide itself - activity.getSupportFragmentManager().addOnBackStackChangedListener(() -> { - if (bottomSheetBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED) { - bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED); - } - }); - } - - private void updateOverlayPlayQueueButtonVisibility() { - final boolean isPlayQueueEmpty = - player == null // no player => no play queue :) - || player.getPlayQueue() == null - || player.getPlayQueue().isEmpty(); - if (binding != null) { - // binding is null when rotating the device... - binding.overlayPlayQueueButton.setVisibility( - isPlayQueueEmpty ? View.GONE : View.VISIBLE); - } - } - - private void updateOverlayData(@Nullable final String overlayTitle, - @Nullable final String uploader, - @NonNull final List thumbnails) { - binding.overlayTitleTextView.setText(isEmpty(overlayTitle) ? "" : overlayTitle); - binding.overlayChannelTextView.setText(isEmpty(uploader) ? "" : uploader); - binding.overlayThumbnail.setImageDrawable(null); - CoilHelper.INSTANCE.loadDetailsThumbnail(binding.overlayThumbnail, thumbnails); - } - - private void setOverlayPlayPauseImage(final boolean playerIsPlaying) { - final int drawable = playerIsPlaying - ? R.drawable.ic_pause - : R.drawable.ic_play_arrow; - binding.overlayPlayPauseButton.setImageResource(drawable); - } - - private void setOverlayLook(final AppBarLayout appBar, - final AppBarLayout.Behavior behavior, - final float slideOffset) { - // SlideOffset < 0 when mini player is about to close via swipe. - // Stop animation in this case - if (behavior == null || slideOffset < 0) { - return; - } - binding.overlayLayout.setAlpha(Math.min(MAX_OVERLAY_ALPHA, 1 - slideOffset)); - // These numbers are not special. They just do a cool transition - behavior.setTopAndBottomOffset( - (int) (-binding.detailThumbnailImageView.getHeight() * 2 * (1 - slideOffset) / 3)); - appBar.requestLayout(); - } - - private void setOverlayElementsClickable(final boolean enable) { - binding.overlayThumbnail.setClickable(enable); - binding.overlayThumbnail.setLongClickable(enable); - binding.overlayMetadataLayout.setClickable(enable); - binding.overlayMetadataLayout.setLongClickable(enable); - binding.overlayButtonsLayout.setClickable(enable); - binding.overlayPlayQueueButton.setClickable(enable); - binding.overlayPlayPauseButton.setClickable(enable); - binding.overlayCloseButton.setClickable(enable); - } - - // helpers to check the state of player and playerService - boolean isPlayerAvailable() { - return player != null; - } - - boolean noPlayerServiceAvailable() { - return playerService == null; - } - - boolean isPlayerAndPlayerServiceAvailable() { - return player != null && playerService != null; - } - - public Optional getRoot() { - return Optional.ofNullable(player) - .flatMap(player1 -> player1.UIs().getOpt(VideoPlayerUi.class)) - .map(playerUi -> playerUi.getBinding().getRoot()); - } - - private void updateBottomSheetState(final int newState) { - bottomSheetState = newState; - if (newState != BottomSheetBehavior.STATE_DRAGGING - && newState != BottomSheetBehavior.STATE_SETTLING) { - lastStableBottomSheetState = newState; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt new file mode 100644 index 000000000..407936926 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt @@ -0,0 +1,2316 @@ +package org.schabi.newpipe.fragments.detail + +import android.animation.ValueAnimator +import android.annotation.SuppressLint +import android.app.Activity +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.SharedPreferences.OnSharedPreferenceChangeListener +import android.content.pm.ActivityInfo +import android.database.ContentObserver +import android.graphics.Color +import android.graphics.Rect +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.provider.Settings +import android.util.DisplayMetrics +import android.util.Log +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.view.View.OnLongClickListener +import android.view.View.OnTouchListener +import android.view.ViewGroup +import android.view.ViewTreeObserver.OnPreDrawListener +import android.view.WindowManager +import android.view.animation.DecelerateInterpolator +import android.widget.FrameLayout +import android.widget.RelativeLayout +import android.widget.Toast +import androidx.annotation.AttrRes +import androidx.annotation.StringRes +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.content.res.AppCompatResources +import androidx.appcompat.widget.Toolbar +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.content.ContextCompat +import androidx.core.content.edit +import androidx.core.net.toUri +import androidx.core.os.postDelayed +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.preference.PreferenceManager +import coil3.util.CoilUtils +import com.evernote.android.state.State +import com.google.android.exoplayer2.PlaybackException +import com.google.android.exoplayer2.PlaybackParameters +import com.google.android.material.appbar.AppBarLayout +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.disposables.Disposable +import io.reactivex.rxjava3.schedulers.Schedulers +import org.schabi.newpipe.App +import org.schabi.newpipe.R +import org.schabi.newpipe.database.stream.model.StreamEntity +import org.schabi.newpipe.databinding.FragmentVideoDetailBinding +import org.schabi.newpipe.download.DownloadDialog +import org.schabi.newpipe.error.ErrorInfo +import org.schabi.newpipe.error.ErrorUtil.Companion.showSnackbar +import org.schabi.newpipe.error.ErrorUtil.Companion.showUiErrorSnackbar +import org.schabi.newpipe.error.ReCaptchaActivity +import org.schabi.newpipe.error.UserAction +import org.schabi.newpipe.extractor.Image +import org.schabi.newpipe.extractor.NewPipe +import org.schabi.newpipe.extractor.StreamingService.ServiceInfo.MediaCapability +import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException +import org.schabi.newpipe.extractor.exceptions.ExtractionException +import org.schabi.newpipe.extractor.stream.Stream +import org.schabi.newpipe.extractor.stream.StreamExtractor +import org.schabi.newpipe.extractor.stream.StreamInfo +import org.schabi.newpipe.extractor.stream.StreamType +import org.schabi.newpipe.fragments.BackPressable +import org.schabi.newpipe.fragments.BaseStateFragment +import org.schabi.newpipe.fragments.EmptyFragment +import org.schabi.newpipe.fragments.MainFragment +import org.schabi.newpipe.fragments.list.comments.CommentsFragment.Companion.getInstance +import org.schabi.newpipe.fragments.list.videos.RelatedItemsFragment.Companion.getInstance +import org.schabi.newpipe.ktx.AnimationType +import org.schabi.newpipe.ktx.animate +import org.schabi.newpipe.ktx.animateRotation +import org.schabi.newpipe.local.dialog.PlaylistDialog +import org.schabi.newpipe.local.history.HistoryRecordManager +import org.schabi.newpipe.local.playlist.LocalPlaylistFragment +import org.schabi.newpipe.player.Player +import org.schabi.newpipe.player.PlayerService +import org.schabi.newpipe.player.PlayerType +import org.schabi.newpipe.player.event.OnKeyDownListener +import org.schabi.newpipe.player.event.PlayerServiceExtendedEventListener +import org.schabi.newpipe.player.helper.PlayerHelper +import org.schabi.newpipe.player.helper.PlayerHolder +import org.schabi.newpipe.player.playqueue.PlayQueue +import org.schabi.newpipe.player.playqueue.SinglePlayQueue +import org.schabi.newpipe.player.ui.MainPlayerUi +import org.schabi.newpipe.player.ui.VideoPlayerUi +import org.schabi.newpipe.util.DependentPreferenceHelper +import org.schabi.newpipe.util.DeviceUtils +import org.schabi.newpipe.util.ExtractorHelper +import org.schabi.newpipe.util.InfoCache +import org.schabi.newpipe.util.ListHelper +import org.schabi.newpipe.util.Localization +import org.schabi.newpipe.util.NO_SERVICE_ID +import org.schabi.newpipe.util.NavigationHelper +import org.schabi.newpipe.util.PermissionHelper +import org.schabi.newpipe.util.PermissionHelper.checkStoragePermissions +import org.schabi.newpipe.util.PlayButtonHelper +import org.schabi.newpipe.util.StreamTypeUtil +import org.schabi.newpipe.util.ThemeHelper +import org.schabi.newpipe.util.external_communication.KoreUtils +import org.schabi.newpipe.util.external_communication.ShareUtils +import org.schabi.newpipe.util.image.CoilHelper +import java.util.LinkedList +import java.util.concurrent.TimeUnit +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + +class VideoDetailFragment : + BaseStateFragment(), + BackPressable, + PlayerServiceExtendedEventListener, + OnKeyDownListener { + + // stream info + @JvmField @State var serviceId: Int = NO_SERVICE_ID + @JvmField @State var title: String = "" + @JvmField @State var url: String? = null + private var currentInfo: StreamInfo? = null + + // player objects + private var playQueue: PlayQueue? = null + @JvmField @State var autoPlayEnabled: Boolean = true + private var playerService: PlayerService? = null + private var player: Player? = null + + // views + // can't make this lateinit because it needs to be set to null when the view is destroyed + private var nullableBinding: FragmentVideoDetailBinding? = null + private val binding: FragmentVideoDetailBinding get() = nullableBinding!! + private lateinit var pageAdapter: TabAdapter + private var settingsContentObserver: ContentObserver? = null + + // tabs + private var showComments = false + private var showRelatedItems = false + private var showDescription = false + private lateinit var selectedTabTag: String + @AttrRes val tabIcons = ArrayList() + @StringRes val tabContentDescriptions = ArrayList() + private var tabSettingsChanged = false + private var lastAppBarVerticalOffset = Int.Companion.MAX_VALUE // prevents useless updates + + private val preferenceChangeListener = + OnSharedPreferenceChangeListener { sharedPreferences, key -> + if (getString(R.string.show_comments_key) == key) { + showComments = sharedPreferences.getBoolean(key, true) + tabSettingsChanged = true + } else if (getString(R.string.show_next_video_key) == key) { + showRelatedItems = sharedPreferences.getBoolean(key, true) + tabSettingsChanged = true + } else if (getString(R.string.show_description_key) == key) { + showDescription = sharedPreferences.getBoolean(key, true) + tabSettingsChanged = true + } + } + + // bottom sheet + @JvmField @State var bottomSheetState: Int = BottomSheetBehavior.STATE_EXPANDED + @JvmField @State var lastStableBottomSheetState: Int = BottomSheetBehavior.STATE_EXPANDED + private lateinit var bottomSheetBehavior: BottomSheetBehavior + private lateinit var bottomSheetCallback: BottomSheetCallback + private lateinit var broadcastReceiver: BroadcastReceiver + + // disposables + private var currentWorker: Disposable? = null + private val disposables = CompositeDisposable() + private var positionSubscriber: Disposable? = null + + /*////////////////////////////////////////////////////////////////////////// + // Service management + ////////////////////////////////////////////////////////////////////////// */ + override fun onServiceConnected(connectedPlayerService: PlayerService) { + playerService = connectedPlayerService + } + + override fun onPlayerConnected(connectedPlayer: Player, playAfterConnect: Boolean) { + player = connectedPlayer + + // It will do nothing if the player is not in fullscreen mode + hideSystemUiIfNeeded() + + if (player?.videoPlayerSelected() != true && !playAfterConnect) { + return + } + + val mainUi = player?.UIs()?.get(MainPlayerUi::class) + if (DeviceUtils.isLandscape(requireContext())) { + // If the video is playing but orientation changed + // let's make the video in fullscreen again + checkLandscape() + } else if (mainUi != null && mainUi.isFullscreen && !mainUi.isVerticalVideo && + // Tablet UI has orientation-independent fullscreen + !DeviceUtils.isTablet(activity) + ) { + // Device is in portrait orientation after rotation but UI is in fullscreen. + // Return back to non-fullscreen state + mainUi.toggleFullscreen() + } + + if (playAfterConnect || (currentInfo != null && this.isAutoplayEnabled && mainUi == null)) { + autoPlayEnabled = true // forcefully start playing + openVideoPlayerAutoFullscreen() + } + updateOverlayPlayQueueButtonVisibility() + } + + override fun onPlayerDisconnected() { + player = null + // the binding could be null at this point, if the app is finishing + if (nullableBinding != null) { + restoreDefaultBrightness() + } + } + + override fun onServiceDisconnected() { + playerService = null + } + + /*////////////////////////////////////////////////////////////////////////// + // Fragment's Lifecycle + ////////////////////////////////////////////////////////////////////////// */ + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val prefs = PreferenceManager.getDefaultSharedPreferences(activity) + showComments = prefs.getBoolean(getString(R.string.show_comments_key), true) + showRelatedItems = prefs.getBoolean(getString(R.string.show_next_video_key), true) + showDescription = prefs.getBoolean(getString(R.string.show_description_key), true) + selectedTabTag = prefs.getString( + getString(R.string.stream_info_selected_tab_key), COMMENTS_TAB_TAG + )!! + prefs.registerOnSharedPreferenceChangeListener(preferenceChangeListener) + + setupBroadcastReceiver() + + settingsContentObserver = object : ContentObserver(Handler(Looper.getMainLooper())) { + override fun onChange(selfChange: Boolean) { + if (activity != null && !PlayerHelper.globalScreenOrientationLocked(activity)) { + activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED) + } + } + } + activity.contentResolver.registerContentObserver( + Settings.System.getUriFor(Settings.System.ACCELEROMETER_ROTATION), false, + settingsContentObserver!! + ) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val newBinding = FragmentVideoDetailBinding.inflate(inflater, container, false) + nullableBinding = newBinding + return newBinding.getRoot() + } + + override fun onPause() { + super.onPause() + currentWorker?.dispose() + restoreDefaultBrightness() + PreferenceManager.getDefaultSharedPreferences(requireContext()).edit { + putString( + getString(R.string.stream_info_selected_tab_key), + pageAdapter.getItemTitle(binding.viewPager.currentItem) + ) + } + } + + override fun onResume() { + super.onResume() + if (DEBUG) { + Log.d(TAG, "onResume() called") + } + + activity.sendBroadcast(Intent(ACTION_VIDEO_FRAGMENT_RESUMED)) + + updateOverlayPlayQueueButtonVisibility() + + setupBrightness() + + if (tabSettingsChanged) { + tabSettingsChanged = false + initTabs() + currentInfo?.let { updateTabs(it) } + } + + // Check if it was loading when the fragment was stopped/paused + if (wasLoading.getAndSet(false) && !wasCleared()) { + startLoading(false) + } + } + + override fun onStop() { + super.onStop() + + if (!activity.isChangingConfigurations) { + activity.sendBroadcast(Intent(ACTION_VIDEO_FRAGMENT_STOPPED)) + } + } + + override fun onDestroy() { + super.onDestroy() + + // Stop the service when user leaves the app with double back press + // if video player is selected. Otherwise unbind + if (activity.isFinishing && player?.videoPlayerSelected() == true) { + PlayerHolder.stopService() + } else { + PlayerHolder.setListener(null) + } + + PreferenceManager.getDefaultSharedPreferences(activity) + .unregisterOnSharedPreferenceChangeListener(preferenceChangeListener) + activity.unregisterReceiver(broadcastReceiver) + activity.contentResolver.unregisterContentObserver(settingsContentObserver!!) + + positionSubscriber?.dispose() + currentWorker?.dispose() + disposables.clear() + positionSubscriber = null + currentWorker = null + bottomSheetBehavior.removeBottomSheetCallback(bottomSheetCallback) + + if (activity.isFinishing) { + playQueue = null + currentInfo = null + stack = LinkedList() + } + } + + override fun onDestroyView() { + super.onDestroyView() + nullableBinding = null + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode == ReCaptchaActivity.RECAPTCHA_REQUEST) { + if (resultCode == Activity.RESULT_OK) { + NavigationHelper.openVideoDetailFragment( + requireContext(), getFM(), serviceId, url, title, null, false + ) + } else { + Log.e(TAG, "ReCaptcha failed") + } + } else { + Log.e(TAG, "Request code from activity not supported [$requestCode]") + } + } + + /*////////////////////////////////////////////////////////////////////////// + // OnClick + ////////////////////////////////////////////////////////////////////////// */ + private fun setOnClickListeners() { + binding.detailTitleRootLayout.setOnClickListener { toggleTitleAndSecondaryControls() } + binding.detailUploaderRootLayout.setOnClickListener( + makeOnClickListener { info -> + if (info.subChannelUrl.isEmpty()) { + if (info.uploaderUrl.isNotEmpty()) { + openChannel(info.uploaderUrl, info.uploaderName, info.serviceId) + } else if (DEBUG) { + Log.w(TAG, "Can't open sub-channel because we got no channel URL") + } + } else { + openChannel(info.subChannelUrl, info.subChannelName, info.serviceId) + } + } + ) + binding.detailThumbnailRootLayout.setOnClickListener { + autoPlayEnabled = true // forcefully start playing + // FIXME Workaround #7427 + player?.setRecovery() + openVideoPlayerAutoFullscreen() + } + + binding.detailControlsBackground.setOnClickListener { openBackgroundPlayer(false) } + binding.detailControlsPopup.setOnClickListener { openPopupPlayer(false) } + binding.detailControlsPlaylistAppend.setOnClickListener( + makeOnClickListener { info -> + if (getFM() != null) { + val fragment = getParentFragmentManager().findFragmentById(R.id.fragment_holder) + + // commit previous pending changes to database + if (fragment is LocalPlaylistFragment) { + fragment.saveImmediate() + } else if (fragment is MainFragment) { + fragment.commitPlaylistTabs() + } + + disposables.add( + PlaylistDialog.createCorrespondingDialog( + requireContext(), + listOf(StreamEntity(info)) + ) { dialog -> dialog.show(getParentFragmentManager(), TAG) } + ) + } + } + ) + binding.detailControlsDownload.setOnClickListener { + if (checkStoragePermissions(activity, PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE)) { + openDownloadDialog() + } + } + binding.detailControlsShare.setOnClickListener( + makeOnClickListener { info -> + ShareUtils.shareText(requireContext(), info.name, info.url, info.thumbnails) + } + ) + binding.detailControlsOpenInBrowser.setOnClickListener( + makeOnClickListener { info -> + ShareUtils.openUrlInBrowser(requireContext(), info.url) + } + ) + binding.detailControlsPlayWithKodi.setOnClickListener( + makeOnClickListener { info -> + KoreUtils.playWithKore(requireContext(), info.url.toUri()) + } + ) + if (DEBUG) { + binding.detailControlsCrashThePlayer.setOnClickListener { + VideoDetailPlayerCrasher.onCrashThePlayer(requireContext(), player) + } + } + + val overlayListener = View.OnClickListener { + bottomSheetBehavior.setState(BottomSheetBehavior.STATE_EXPANDED) + } + binding.overlayThumbnail.setOnClickListener(overlayListener) + binding.overlayMetadataLayout.setOnClickListener(overlayListener) + binding.overlayButtonsLayout.setOnClickListener(overlayListener) + binding.overlayCloseButton.setOnClickListener { + bottomSheetBehavior.setState(BottomSheetBehavior.STATE_HIDDEN) + } + binding.overlayPlayQueueButton.setOnClickListener { + NavigationHelper.openPlayQueue(requireContext()) + } + binding.overlayPlayPauseButton.setOnClickListener { + if (!playerIsStopped) { + player!!.playPause() + player!!.UIs().get(VideoPlayerUi::class)?.hideControls(0, 0) + showSystemUi() + } else { + autoPlayEnabled = true // forcefully start playing + openVideoPlayer(false) + } + setOverlayPlayPauseImage(player?.isPlaying == true) + } + } + + private fun makeOnClickListener(listener: (StreamInfo) -> Unit): View.OnClickListener { + return View.OnClickListener { + currentInfo?.takeIf { !isLoading.get() }?.let(listener) + } + } + + private fun setOnLongClickListeners() { + binding.detailTitleRootLayout.setOnLongClickListener { + binding.detailVideoTitleView.text?.toString()?.let { + if (!it.isBlank()) { + ShareUtils.copyToClipboard(requireContext(), it) + return@setOnLongClickListener true + } + } + return@setOnLongClickListener false + } + binding.detailUploaderRootLayout.setOnLongClickListener( + makeOnLongClickListener { info -> + if (info.subChannelUrl.isEmpty()) { + Log.w(TAG, "Can't open parent channel because we got no parent channel URL") + } else { + openChannel(info.uploaderUrl, info.uploaderName, info.serviceId) + } + } + ) + + binding.detailControlsBackground.setOnLongClickListener( + makeOnLongClickListener { info -> + openBackgroundPlayer(true) + } + ) + binding.detailControlsPopup.setOnLongClickListener( + makeOnLongClickListener { info -> + openPopupPlayer(true) + } + ) + binding.detailControlsDownload.setOnLongClickListener( + makeOnLongClickListener { info -> + NavigationHelper.openDownloads(activity) + } + ) + + val overlayListener = makeOnLongClickListener { info -> + openChannel(info.uploaderUrl, info.uploaderName, info.serviceId) + } + binding.overlayThumbnail.setOnLongClickListener(overlayListener) + binding.overlayMetadataLayout.setOnLongClickListener(overlayListener) + } + + private fun makeOnLongClickListener(listener: (StreamInfo) -> Unit): OnLongClickListener { + return OnLongClickListener { + currentInfo?.takeIf { !isLoading.get() }?.let(listener) != null + } + } + + private fun openChannel(subChannelUrl: String?, subChannelName: String, serviceId: Int) { + try { + NavigationHelper.openChannelFragment(getFM(), serviceId, subChannelUrl, subChannelName) + } catch (e: Exception) { + showUiErrorSnackbar(this, "Opening channel fragment", e) + } + } + + private fun toggleTitleAndSecondaryControls() { + if (binding.detailSecondaryControlPanel.isGone) { + binding.detailVideoTitleView.setMaxLines(10) + binding.detailToggleSecondaryControlsView + .animateRotation(VideoPlayerUi.DEFAULT_CONTROLS_DURATION, 180) + binding.detailSecondaryControlPanel.visibility = View.VISIBLE + } else { + binding.detailVideoTitleView.setMaxLines(1) + binding.detailToggleSecondaryControlsView + .animateRotation(VideoPlayerUi.DEFAULT_CONTROLS_DURATION, 0) + binding.detailSecondaryControlPanel.visibility = View.GONE + } + // view pager height has changed, update the tab layout + updateTabLayoutVisibility() + } + + /*////////////////////////////////////////////////////////////////////////// + // Init + ////////////////////////////////////////////////////////////////////////// */ + // called from onViewCreated in {@link BaseFragment#onViewCreated} + override fun initViews(rootView: View?, savedInstanceState: Bundle?) { + super.initViews(rootView, savedInstanceState) + + pageAdapter = TabAdapter(getChildFragmentManager()) + binding.viewPager.setAdapter(pageAdapter) + binding.tabLayout.setupWithViewPager(binding.viewPager) + + binding.detailThumbnailRootLayout.requestFocus() + + binding.detailControlsPlayWithKodi.isVisible = + KoreUtils.shouldShowPlayWithKodi(requireContext(), serviceId) + binding.detailControlsCrashThePlayer.isVisible = + DEBUG && PreferenceManager.getDefaultSharedPreferences(requireContext()) + .getBoolean(getString(R.string.show_crash_the_player_key), false) + + accommodateForTvAndDesktopMode() + } + + @SuppressLint("ClickableViewAccessibility") + override fun initListeners() { + super.initListeners() + + setOnClickListeners() + setOnLongClickListeners() + + val controlsTouchListener = OnTouchListener { view, motionEvent -> + if (motionEvent.action == MotionEvent.ACTION_DOWN && + PlayButtonHelper.shouldShowHoldToAppendTip(activity) + ) { + binding.touchAppendDetail.animate(true, 250, AnimationType.ALPHA, 0) { + binding.touchAppendDetail.animate(false, 1500, AnimationType.ALPHA, 1000) + } + } + false + } + binding.detailControlsBackground.setOnTouchListener(controlsTouchListener) + binding.detailControlsPopup.setOnTouchListener(controlsTouchListener) + + binding.appBarLayout.addOnOffsetChangedListener { layout, verticalOffset -> + // prevent useless updates to tab layout visibility if nothing changed + if (verticalOffset != lastAppBarVerticalOffset) { + lastAppBarVerticalOffset = verticalOffset + // the view was scrolled + updateTabLayoutVisibility() + } + } + + setupBottomPlayer() + if (!PlayerHolder.isBound) { + setHeightThumbnail() + } else { + PlayerHolder.startService(false, this) + } + } + + override fun onKeyDown(keyCode: Int): Boolean { + return player?.UIs()?.get(VideoPlayerUi::class)?.onKeyDown(keyCode) == true + } + + override fun onBackPressed(): Boolean { + if (DEBUG) { + Log.d(TAG, "onBackPressed() called") + } + + // If we are in fullscreen mode just exit from it via first back press + if (this.isFullscreen) { + if (!DeviceUtils.isTablet(activity)) { + player!!.pause() + } + restoreDefaultOrientation() + setAutoPlay(false) + return true + } + + // If we have something in history of played items we replay it here + if (player?.videoPlayerSelected() == true && player?.playQueue?.previous() == true) { + return true // no code here, as previous() was used in the if + } + + // That means that we are on the start of the stack, + if (stack.size <= 1) { + restoreDefaultOrientation() + return false // let MainActivity handle the onBack (e.g. to minimize the mini player) + } + + // Remove top + stack.pop() + // Get stack item from the new top + setupFromHistoryItem(stack.peek()!!) + + return true + } + + private fun setupFromHistoryItem(item: StackItem) { + setAutoPlay(false) + hideMainPlayerOnLoadingNewStream() + + setInitialData(item.serviceId, item.url, item.title ?: "", item.playQueue) + startLoading(false) + + // Maybe an item was deleted in background activity + if (item.playQueue.item == null) { + return + } + + val playQueueItem = item.playQueue.item + // Update title, url, uploader from the last item in the stack (it's current now) + if (playQueueItem != null && playerIsStopped) { + updateOverlayData(playQueueItem.title, playQueueItem.uploader, playQueueItem.thumbnails) + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Info loading and handling + ////////////////////////////////////////////////////////////////////////// */ + override fun doInitialLoadLogic() { + if (wasCleared()) { + return + } + + when (val info = currentInfo) { + null -> prepareAndLoadInfo() + else -> prepareAndHandleInfoIfNeededAfterDelay(info, false, 50) + } + } + + fun selectAndLoadVideo( + newServiceId: Int, + newUrl: String, + newTitle: String, + newQueue: PlayQueue? + ) { + if (newQueue != null && playQueue?.item?.url != newUrl) { + // Preloading can be disabled since playback is surely being replaced. + player?.disablePreloadingOfCurrentTrack() + } + + setInitialData(newServiceId, newUrl, newTitle, newQueue) + startLoading(false, true) + } + + private fun prepareAndHandleInfoIfNeededAfterDelay( + info: StreamInfo, + scrollToTop: Boolean, + delay: Long + ) { + Handler(Looper.getMainLooper()).postDelayed(delay) { + if (activity == null) { + return@postDelayed + } + // Data can already be drawn, don't spend time twice + if (info.name == binding.detailVideoTitleView.getText().toString()) { + return@postDelayed + } + prepareAndHandleInfo(info, scrollToTop) + } + } + + private fun prepareAndHandleInfo(info: StreamInfo, scrollToTop: Boolean) { + if (DEBUG) { + Log.d(TAG, "prepareAndHandleInfo(info=[$info], scrollToTop=[$scrollToTop]) called") + } + + showLoading() + initTabs() + + if (scrollToTop) { + scrollToTop() + } + handleResult(info) + showContent() + } + + private fun prepareAndLoadInfo() { + scrollToTop() + startLoading(false) + } + + public override fun startLoading(forceLoad: Boolean) { + startLoading(forceLoad, null) + } + + private fun startLoading(forceLoad: Boolean, addToBackStack: Boolean?) { + super.startLoading(forceLoad) + + initTabs() + currentInfo = null + currentWorker?.dispose() + + runWorker(forceLoad, addToBackStack ?: stack.isEmpty()) + } + + private fun runWorker(forceLoad: Boolean, addToBackStack: Boolean) { + val prefs = PreferenceManager.getDefaultSharedPreferences(activity) + currentWorker = ExtractorHelper.getStreamInfo(serviceId, url, forceLoad) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { result -> + isLoading.set(false) + hideMainPlayerOnLoadingNewStream() + if (result.ageLimit != StreamExtractor.NO_AGE_LIMIT && + !prefs.getBoolean(getString(R.string.show_age_restricted_content), false) + ) { + hideAgeRestrictedContent() + } else { + handleResult(result) + showContent() + if (addToBackStack) { + if (playQueue == null) { + playQueue = SinglePlayQueue(result) + } + if (stack.peek()?.playQueue != playQueue) { // also if stack empty (!) + stack.push(StackItem(serviceId, url, title, playQueue)) + } + } + + if (this.isAutoplayEnabled) { + openVideoPlayerAutoFullscreen() + } + } + }, + { throwable -> + showError( + ErrorInfo(throwable, UserAction.REQUESTED_STREAM, url ?: "no url", serviceId) + ) + } + ) + } + + /*////////////////////////////////////////////////////////////////////////// + // Tabs + ////////////////////////////////////////////////////////////////////////// */ + private fun initTabs() { + pageAdapter.getItemTitle(binding.viewPager.currentItem) + ?.let { tag -> selectedTabTag = tag } + + pageAdapter.clearAllItems() + tabIcons.clear() + tabContentDescriptions.clear() + + if (shouldShowComments()) { + pageAdapter.addFragment(getInstance(serviceId, url), COMMENTS_TAB_TAG) + tabIcons.add(R.drawable.ic_comment) + tabContentDescriptions.add(R.string.comments_tab_description) + } + + if (showRelatedItems && binding.relatedItemsLayout == null) { + // temp empty fragment. will be updated in handleResult + pageAdapter.addFragment(EmptyFragment.newInstance(false), RELATED_TAB_TAG) + tabIcons.add(R.drawable.ic_art_track) + tabContentDescriptions.add(R.string.related_items_tab_description) + } + + if (showDescription) { + // temp empty fragment. will be updated in handleResult + pageAdapter.addFragment(EmptyFragment.newInstance(false), DESCRIPTION_TAB_TAG) + tabIcons.add(R.drawable.ic_description) + tabContentDescriptions.add(R.string.description_tab_description) + } + + if (pageAdapter.count == 0) { + pageAdapter.addFragment(EmptyFragment.newInstance(true), EMPTY_TAB_TAG) + } + pageAdapter.notifyDataSetUpdate() + + if (pageAdapter.count >= 2) { + val position = pageAdapter.getItemPositionByTitle(selectedTabTag) + if (position != -1) { + binding.viewPager.setCurrentItem(position) + } + updateTabIconsAndContentDescriptions() + } + // the page adapter now contains tabs: show the tab layout + updateTabLayoutVisibility() + } + + /** + * To be called whenever [.pageAdapter] is modified, since that triggers a refresh in + * [FragmentVideoDetailBinding.tabLayout] resetting all tab's icons and content + * descriptions. This reads icons from [.tabIcons] and content descriptions from + * [.tabContentDescriptions], which are all set in [.initTabs]. + */ + private fun updateTabIconsAndContentDescriptions() { + for (i in tabIcons.indices) { + val tab = binding.tabLayout.getTabAt(i) + if (tab != null) { + tab.setIcon(tabIcons[i]) + tab.setContentDescription(tabContentDescriptions[i]) + } + } + } + + private fun updateTabs(info: StreamInfo) { + if (showRelatedItems) { + when (val relatedItemsLayout = binding.relatedItemsLayout) { + null -> pageAdapter.updateItem(RELATED_TAB_TAG, getInstance(info)) // phone + else -> { // tablet + TV + getChildFragmentManager().beginTransaction() + .replace(R.id.relatedItemsLayout, getInstance(info)) + .commitAllowingStateLoss() + relatedItemsLayout.isVisible = !this.isFullscreen + } + } + } + + if (showDescription) { + pageAdapter.updateItem(DESCRIPTION_TAB_TAG, DescriptionFragment(info)) + } + + binding.viewPager.visibility = View.VISIBLE + // make sure the tab layout is visible + updateTabLayoutVisibility() + pageAdapter.notifyDataSetUpdate() + updateTabIconsAndContentDescriptions() + } + + private fun shouldShowComments(): Boolean { + return showComments && try { + NewPipe.getService(serviceId).serviceInfo.mediaCapabilities + .contains(MediaCapability.COMMENTS) + } catch (_: ExtractionException) { + false + } + } + + fun updateTabLayoutVisibility() { + if (nullableBinding == null) { + // If binding is null we do not need to and should not do anything with its object(s) + return + } + + if (pageAdapter.count < 2 || binding.viewPager.visibility != View.VISIBLE) { + // hide tab layout if there is only one tab or if the view pager is also hidden + binding.tabLayout.visibility = View.GONE + } else { + // call `post()` to be sure `viewPager.getHitRect()` + // is up to date and not being currently recomputed + binding.tabLayout.post { + getActivity()?.let { activity -> + val pagerHitRect = Rect() + binding.viewPager.getHitRect(pagerHitRect) + + val height = DeviceUtils.getWindowHeight(activity.windowManager) + val viewPagerVisibleHeight = height - pagerHitRect.top + // see TabLayout.DEFAULT_HEIGHT, which is equal to 48dp + val tabLayoutHeight = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, 48f, resources.displayMetrics + ) + + if (viewPagerVisibleHeight > tabLayoutHeight * 2) { + // no translation at all when viewPagerVisibleHeight > tabLayout.height * 3 + binding.tabLayout.translationY = + max(0.0f, tabLayoutHeight * 3 - viewPagerVisibleHeight) + binding.tabLayout.visibility = View.VISIBLE + } else { + // view pager is not visible enough + binding.tabLayout.visibility = View.GONE + } + } + } + } + } + + fun scrollToTop() { + binding.appBarLayout.setExpanded(true, true) + // notify tab layout of scrolling + updateTabLayoutVisibility() + } + + /*////////////////////////////////////////////////////////////////////////// + // Play Utils + ////////////////////////////////////////////////////////////////////////// */ + private fun toggleFullscreenIfInFullscreenMode() { + // If a user watched video inside fullscreen mode and than chose another player + // return to non-fullscreen mode + player?.UIs()?.get(MainPlayerUi::class)?.let { + if (it.isFullscreen) { + it.toggleFullscreen() + } + } + } + + private fun openBackgroundPlayer(append: Boolean) { + val useExternalAudioPlayer = PreferenceManager + .getDefaultSharedPreferences(activity) + .getBoolean(activity.getString(R.string.use_external_audio_player_key), false) + + toggleFullscreenIfInFullscreenMode() + + // FIXME Workaround #7427 + player?.setRecovery() + + if (useExternalAudioPlayer) { + showExternalAudioPlaybackDialog() + } else { + openNormalBackgroundPlayer(append) + } + } + + private fun openPopupPlayer(append: Boolean) { + if (!PermissionHelper.isPopupEnabledElseAsk(activity)) { + return + } + + // See UI changes while remote playQueue changes + if (player == null) { + PlayerHolder.startService(false, this) + } else { + // FIXME Workaround #7427 + player?.setRecovery() + } + + toggleFullscreenIfInFullscreenMode() + + val queue = setupPlayQueueForIntent(append) + if (append) { // resumePlayback: false + NavigationHelper.enqueueOnPlayer(activity, queue, PlayerType.POPUP) + } else { + replaceQueueIfUserConfirms { NavigationHelper.playOnPopupPlayer(activity, queue, true) } + } + } + + /** + * Opens the video player, in fullscreen if needed. In order to open fullscreen, the activity + * is toggled to landscape orientation (which will then cause fullscreen mode). + * + * @param directlyFullscreenIfApplicable whether to open fullscreen if we are not already + * in landscape and screen orientation is locked + */ + fun openVideoPlayer(directlyFullscreenIfApplicable: Boolean) { + if (directlyFullscreenIfApplicable && + !DeviceUtils.isLandscape(requireContext()) && + PlayerHelper.globalScreenOrientationLocked(requireContext()) + ) { + // Make sure the bottom sheet turns out expanded. When this code kicks in the bottom + // sheet could not have fully expanded yet, and thus be in the STATE_SETTLING state. + // When the activity is rotated, and its state is saved and then restored, the bottom + // sheet would forget what it was doing, since even if STATE_SETTLING is restored, it + // doesn't tell which state it was settling to, and thus the bottom sheet settles to + // STATE_COLLAPSED. This can be solved by manually setting the state that will be + // restored (i.e. bottomSheetState) to STATE_EXPANDED. + updateBottomSheetState(BottomSheetBehavior.STATE_EXPANDED) + // toggle landscape in order to open directly in fullscreen + onScreenRotationButtonClicked() + } + + if (PreferenceManager.getDefaultSharedPreferences(activity) + .getBoolean(this.getString(R.string.use_external_video_player_key), false) + ) { + showExternalVideoPlaybackDialog() + } else { + replaceQueueIfUserConfirms { this.openMainPlayer() } + } + } + + /** + * If the option to start directly fullscreen is enabled, calls + * [.openVideoPlayer] with `directlyFullscreenIfApplicable = true`, so that + * if the user is not already in landscape and he has screen orientation locked the activity + * rotates and fullscreen starts. Otherwise, if the option to start directly fullscreen is + * disabled, calls [.openVideoPlayer] with `directlyFullscreenIfApplicable + * = false`, hence preventing it from going directly fullscreen. + */ + fun openVideoPlayerAutoFullscreen() { + openVideoPlayer(PlayerHelper.isStartMainPlayerFullscreenEnabled(requireContext())) + } + + private fun openNormalBackgroundPlayer(append: Boolean) { + // See UI changes while remote playQueue changes + if (player == null) { + PlayerHolder.startService(false, this) + } + + val queue = setupPlayQueueForIntent(append) + if (append) { + NavigationHelper.enqueueOnPlayer(activity, queue, PlayerType.AUDIO) + } else { + replaceQueueIfUserConfirms { + NavigationHelper.playOnBackgroundPlayer(activity, queue, true) + } + } + } + + private fun openMainPlayer() { + if (playerService == null) { + PlayerHolder.startService(autoPlayEnabled, this) + return + } + if (currentInfo == null) { + return + } + + val queue = setupPlayQueueForIntent(false) + tryAddVideoPlayerView() + + val playerIntent = NavigationHelper.getPlayerIntent( + requireContext(), PlayerService::class.java, queue, true, autoPlayEnabled + ) + ContextCompat.startForegroundService(activity, playerIntent) + } + + /** + * When the video detail fragment is already showing details for a video and the user opens a + * new one, the video detail fragment changes all of its old data to the new stream, so if there + * is a video player currently open it should be hidden. This method does exactly that. If + * autoplay is enabled, the underlying player is not stopped completely, since it is going to + * be reused in a few milliseconds and the flickering would be annoying. + */ + private fun hideMainPlayerOnLoadingNewStream() { + val root = this.root + if (root == null || playerService == null || player?.videoPlayerSelected() != true) { + return + } + + removeVideoPlayerView() + if (this.isAutoplayEnabled) { + playerService?.stopForImmediateReusing() + root.visibility = View.GONE + } else { + PlayerHolder.stopService() + } + } + + private fun setupPlayQueueForIntent(append: Boolean): PlayQueue { + if (append) { + return SinglePlayQueue(currentInfo) + } + + var queue = playQueue + // Size can be 0 because queue removes bad stream automatically when error occurs + if (queue == null || queue.isEmpty) { + queue = SinglePlayQueue(currentInfo) + } + + return queue + } + + /*////////////////////////////////////////////////////////////////////////// + // Utils + ////////////////////////////////////////////////////////////////////////// */ + fun setAutoPlay(autoPlay: Boolean) { + this.autoPlayEnabled = autoPlay + } + + private fun startOnExternalPlayer( + context: Context, + info: StreamInfo, + selectedStream: Stream + ) { + NavigationHelper.playOnExternalPlayer( + context, info.name, info.subChannelName, selectedStream + ) + + val recordManager = HistoryRecordManager(requireContext()) + disposables.add( + recordManager.onViewed(info) + .subscribe( + { /* successful */ }, + { throwable -> Log.e(TAG, "Register view failure: ", throwable) } + ) + ) + } + + private val isExternalPlayerEnabled: Boolean + get() = PreferenceManager.getDefaultSharedPreferences(requireContext()) + .getBoolean(getString(R.string.use_external_video_player_key), false) + + @Suppress("NullableBooleanElvis") // ?: true is clearer than != false + private val isAutoplayEnabled: Boolean + // This method overrides default behaviour when setAutoPlay() is called. + get() = autoPlayEnabled && + !this.isExternalPlayerEnabled && + (player?.videoPlayerSelected() ?: true) && // if no player present, consider it video + bottomSheetState != BottomSheetBehavior.STATE_HIDDEN && + PlayerHelper.isAutoplayAllowedByUser(requireContext()) + + private fun tryAddVideoPlayerView() { + if (player != null && view != null) { + // Setup the surface view height, so that it fits the video correctly; this is done also + // here, and not only in the Handler, to avoid a choppy fullscreen rotation animation. + setHeightThumbnail() + } + + // do all the null checks in the posted lambda, too, since the player, the binding and the + // view could be set or unset before the lambda gets executed on the next main thread cycle + Handler(Looper.getMainLooper()).post { + if (player == null || view == null) { + return@post + } + + // setup the surface view height, so that it fits the video correctly + setHeightThumbnail() + + player?.UIs()?.get(MainPlayerUi::class)?.let { playerUi -> + // sometimes binding would be null here, even though getView() != null above u.u + nullableBinding?.let { b -> + // prevent from re-adding a view multiple times + playerUi.removeViewFromParent() + b.playerPlaceholder.addView(playerUi.getBinding().getRoot()) + playerUi.setupVideoSurfaceIfNeeded() + } + } + } + } + + private fun removeVideoPlayerView() { + makeDefaultHeightForVideoPlaceholder() + player?.UIs()?.get(VideoPlayerUi::class)?.removeViewFromParent() + } + + private fun makeDefaultHeightForVideoPlaceholder() { + if (view == null) { + return + } + + binding.playerPlaceholder.layoutParams.height = FrameLayout.LayoutParams.MATCH_PARENT + binding.playerPlaceholder.requestLayout() + } + + private val preDrawListener: OnPreDrawListener = OnPreDrawListener { + view?.let { view -> + val decorView = if (DeviceUtils.isInMultiWindow(activity)) + view + else + activity.window.decorView + setHeightThumbnail(decorView.height, resources.displayMetrics) + view.getViewTreeObserver().removeOnPreDrawListener(preDrawListener) + } + return@OnPreDrawListener false + } + + /** + * Method which controls the size of thumbnail and the size of main player inside + * a layout with thumbnail. It decides what height the player should have in both + * screen orientations. It knows about multiWindow feature + * and about videos with aspectRatio ZOOM (the height for them will be a bit higher, + * [.MAX_PLAYER_HEIGHT]) + */ + private fun setHeightThumbnail() { + val metrics = resources.displayMetrics + requireView().getViewTreeObserver().removeOnPreDrawListener(preDrawListener) + + if (this.isFullscreen) { + val height = ( + if (DeviceUtils.isInMultiWindow(activity)) + requireView() + else + activity.window.decorView + ).height + // Height is zero when the view is not yet displayed like after orientation change + if (height != 0) { + setHeightThumbnail(height, metrics) + } else { + requireView().getViewTreeObserver().addOnPreDrawListener(preDrawListener) + } + } else { + val isPortrait = metrics.heightPixels > metrics.widthPixels + val height = ( + if (isPortrait) + metrics.widthPixels / (16.0f / 9.0f) + else + metrics.heightPixels / 2.0f + ).toInt() + setHeightThumbnail(height, metrics) + } + } + + private fun setHeightThumbnail(newHeight: Int, metrics: DisplayMetrics) { + binding.detailThumbnailImageView.setLayoutParams( + FrameLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, newHeight) + ) + binding.detailThumbnailImageView.setMinimumHeight(newHeight) + player?.UIs()?.get(VideoPlayerUi::class)?.let { + val maxHeight = (metrics.heightPixels * MAX_PLAYER_HEIGHT).toInt() + it.binding.surfaceView.setHeights( + newHeight, + if (it.isFullscreen) newHeight else maxHeight + ) + } + } + + private fun showContent() { + binding.detailContentRootHiding.visibility = View.VISIBLE + } + + private fun setInitialData( + newServiceId: Int, + newUrl: String?, + newTitle: String, + newPlayQueue: PlayQueue? + ) { + this.serviceId = newServiceId + this.url = newUrl + this.title = newTitle + this.playQueue = newPlayQueue + } + + private fun setErrorImage() { + if (nullableBinding == null || activity == null) { + return + } + + binding.detailThumbnailImageView.setImageDrawable( + AppCompatResources.getDrawable(requireContext(), R.drawable.not_available_monkey) + ) + binding.detailThumbnailImageView.animate(false, 0, AnimationType.ALPHA, 0) { + binding.detailThumbnailImageView.animate(true, 500) + } + } + + override fun handleError() { + super.handleError() + setErrorImage() + + // hide related streams for tablets + binding.relatedItemsLayout?.visibility = View.INVISIBLE + + // hide comments / related streams / description tabs + binding.viewPager.visibility = View.GONE + binding.tabLayout.visibility = View.GONE + } + + private fun hideAgeRestrictedContent() { + showTextError( + getString( + R.string.restricted_video, + getString(R.string.show_age_restricted_content_title) + ) + ) + } + + private fun setupBroadcastReceiver() { + broadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent) { + when (intent.action) { + ACTION_SHOW_MAIN_PLAYER -> bottomSheetBehavior.setState(BottomSheetBehavior.STATE_EXPANDED) + ACTION_HIDE_MAIN_PLAYER -> bottomSheetBehavior.setState(BottomSheetBehavior.STATE_HIDDEN) + ACTION_PLAYER_STARTED -> { + // If the state is not hidden we don't need to show the mini player + if (bottomSheetBehavior.getState() == BottomSheetBehavior.STATE_HIDDEN) { + bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED) + } + // Rebound to the service if it was closed via notification or mini player + if (!PlayerHolder.isBound) { + PlayerHolder.startService(false, this@VideoDetailFragment) + } + } + } + } + } + val intentFilter = IntentFilter() + intentFilter.addAction(ACTION_SHOW_MAIN_PLAYER) + intentFilter.addAction(ACTION_HIDE_MAIN_PLAYER) + intentFilter.addAction(ACTION_PLAYER_STARTED) + ContextCompat.registerReceiver(activity, broadcastReceiver, intentFilter, ContextCompat.RECEIVER_EXPORTED) + } + + /*////////////////////////////////////////////////////////////////////////// + // Orientation listener + ////////////////////////////////////////////////////////////////////////// */ + private fun restoreDefaultOrientation() { + if (player?.videoPlayerSelected() == true) { + toggleFullscreenIfInFullscreenMode() + } + + // This will show systemUI and pause the player. + // User can tap on Play button and video will be in fullscreen mode again + // Note for tablet: trying to avoid orientation changes since it's not easy + // to physically rotate the tablet every time + if (activity != null && !DeviceUtils.isTablet(activity)) { + activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED) + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Contract + ////////////////////////////////////////////////////////////////////////// */ + override fun showLoading() { + super.showLoading() + + // if data is already cached, transition from VISIBLE -> INVISIBLE -> VISIBLE is not required + if (!ExtractorHelper.isCached(serviceId, url!!, InfoCache.Type.STREAM)) { + binding.detailContentRootHiding.visibility = View.INVISIBLE + } + + binding.detailThumbnailPlayButton.animate(false, 50) + binding.detailDurationView.animate(false, 100) + binding.detailPositionView.visibility = View.GONE + binding.positionView.visibility = View.GONE + + binding.detailVideoTitleView.text = title + binding.detailVideoTitleView.setMaxLines(1) + binding.detailVideoTitleView.animate(true, 0) + + binding.detailToggleSecondaryControlsView.visibility = View.GONE + binding.detailTitleRootLayout.isClickable = false + binding.detailSecondaryControlPanel.visibility = View.GONE + + binding.relatedItemsLayout?.isVisible = showRelatedItems && !this.isFullscreen + + CoilUtils.dispose(binding.detailThumbnailImageView) + CoilUtils.dispose(binding.detailSubChannelThumbnailView) + CoilUtils.dispose(binding.overlayThumbnail) + CoilUtils.dispose(binding.detailUploaderThumbnailView) + + binding.detailThumbnailImageView.setImageBitmap(null) + binding.detailSubChannelThumbnailView.setImageBitmap(null) + } + + override fun handleResult(info: StreamInfo) { + super.handleResult(info) + + currentInfo = info + setInitialData(info.serviceId, info.originalUrl, info.name, playQueue) + + updateTabs(info) + + binding.detailThumbnailPlayButton.animate(true, 200) + binding.detailVideoTitleView.text = title + + binding.detailSubChannelThumbnailView.visibility = View.GONE + + if (info.subChannelName.isEmpty()) { + displayUploaderAsSubChannel(info) + } else { + displayBothUploaderAndSubChannel(info) + } + + if (info.viewCount >= 0) { + binding.detailViewCountView.text = + if (info.streamType == StreamType.AUDIO_LIVE_STREAM) { + Localization.listeningCount(activity, info.viewCount) + } else if (info.streamType == StreamType.LIVE_STREAM) { + Localization.localizeWatchingCount(activity, info.viewCount) + } else { + Localization.localizeViewCount(activity, info.viewCount) + } + binding.detailViewCountView.visibility = View.VISIBLE + } else { + binding.detailViewCountView.visibility = View.GONE + } + + if (info.dislikeCount == -1L && info.likeCount == -1L) { + binding.detailThumbsDownImgView.visibility = View.VISIBLE + binding.detailThumbsUpImgView.visibility = View.VISIBLE + binding.detailThumbsUpCountView.visibility = View.GONE + binding.detailThumbsDownCountView.visibility = View.GONE + binding.detailThumbsDisabledView.visibility = View.VISIBLE + } else { + if (info.dislikeCount >= 0) { + binding.detailThumbsDownCountView.text = + Localization.shortCount(activity, info.dislikeCount) + binding.detailThumbsDownCountView.visibility = View.VISIBLE + binding.detailThumbsDownImgView.visibility = View.VISIBLE + } else { + binding.detailThumbsDownCountView.visibility = View.GONE + binding.detailThumbsDownImgView.visibility = View.GONE + } + + if (info.likeCount >= 0) { + binding.detailThumbsUpCountView.text = + Localization.shortCount(activity, info.likeCount) + binding.detailThumbsUpCountView.visibility = View.VISIBLE + binding.detailThumbsUpImgView.visibility = View.VISIBLE + } else { + binding.detailThumbsUpCountView.visibility = View.GONE + binding.detailThumbsUpImgView.visibility = View.GONE + } + binding.detailThumbsDisabledView.visibility = View.GONE + } + + if (info.duration > 0) { + binding.detailDurationView.text = Localization.getDurationString(info.duration) + binding.detailDurationView.setBackgroundColor( + ContextCompat.getColor(activity, R.color.duration_background_color) + ) + binding.detailDurationView.animate(true, 100) + } else if (info.streamType == StreamType.LIVE_STREAM) { + binding.detailDurationView.setText(R.string.duration_live) + binding.detailDurationView.setBackgroundColor( + ContextCompat.getColor(activity, R.color.live_duration_background_color) + ) + binding.detailDurationView.animate(true, 100) + } else { + binding.detailDurationView.visibility = View.GONE + } + + binding.detailTitleRootLayout.isClickable = true + binding.detailToggleSecondaryControlsView.rotation = 0f + binding.detailToggleSecondaryControlsView.visibility = View.VISIBLE + binding.detailSecondaryControlPanel.visibility = View.GONE + + checkUpdateProgressInfo(info) + CoilHelper.loadDetailsThumbnail(binding.detailThumbnailImageView, info.thumbnails) + ExtractorHelper.showMetaInfoInTextView( + info.metaInfo, binding.detailMetaInfoTextView, + binding.detailMetaInfoSeparator, disposables + ) + + if (playerIsStopped) { + updateOverlayData(info.name, info.uploaderName, info.thumbnails) + } + + if (!info.errors.isEmpty()) { + // Bandcamp fan pages are not yet supported and thus a ContentNotAvailableException is + // thrown. This is not an error and thus should not be shown to the user. + info.errors.removeIf { + it is ContentNotSupportedException && "Fan pages are not supported" == it.message + } + + if (!info.errors.isEmpty()) { + showSnackBarError( + ErrorInfo(info.errors, UserAction.REQUESTED_STREAM, info.url, info) + ) + } + } + + binding.detailControlsDownload.isVisible = !StreamTypeUtil.isLiveStream(info.streamType) + + val hasAudioStreams = info.videoStreams.isNotEmpty() || info.audioStreams.isNotEmpty() + binding.detailControlsBackground.isVisible = hasAudioStreams + + val hasVideoStreams = info.videoStreams.isNotEmpty() || info.videoOnlyStreams.isNotEmpty() + binding.detailControlsPopup.isVisible = hasVideoStreams + binding.detailThumbnailPlayButton.setImageResource( + if (hasVideoStreams) R.drawable.ic_play_arrow_shadow else R.drawable.ic_headset_shadow + ) + } + + private fun displayUploaderAsSubChannel(info: StreamInfo) { + binding.detailSubChannelTextView.text = info.uploaderName + binding.detailSubChannelTextView.visibility = View.VISIBLE + binding.detailSubChannelTextView.setSelected(true) + + if (info.uploaderSubscriberCount > -1) { + binding.detailUploaderTextView.text = + Localization.shortSubscriberCount(activity, info.uploaderSubscriberCount) + binding.detailUploaderTextView.visibility = View.VISIBLE + } else { + binding.detailUploaderTextView.visibility = View.GONE + } + + CoilHelper.loadAvatar(binding.detailSubChannelThumbnailView, info.uploaderAvatars) + binding.detailSubChannelThumbnailView.visibility = View.VISIBLE + binding.detailUploaderThumbnailView.visibility = View.GONE + } + + private fun displayBothUploaderAndSubChannel(info: StreamInfo) { + binding.detailSubChannelTextView.text = info.subChannelName + binding.detailSubChannelTextView.visibility = View.VISIBLE + binding.detailSubChannelTextView.setSelected(true) + + val subText = StringBuilder() + if (info.uploaderName.isNotEmpty()) { + subText.append(getString(R.string.video_detail_by, info.uploaderName)) + } + if (info.uploaderSubscriberCount > -1) { + if (subText.isNotEmpty()) { + subText.append(Localization.DOT_SEPARATOR) + } + subText.append( + Localization.shortSubscriberCount(activity, info.uploaderSubscriberCount) + ) + } + + if (subText.isEmpty()) { + binding.detailUploaderTextView.visibility = View.GONE + } else { + binding.detailUploaderTextView.text = subText + binding.detailUploaderTextView.visibility = View.VISIBLE + binding.detailUploaderTextView.setSelected(true) + } + + CoilHelper.loadAvatar(binding.detailSubChannelThumbnailView, info.subChannelAvatars) + binding.detailSubChannelThumbnailView.visibility = View.VISIBLE + CoilHelper.loadAvatar(binding.detailUploaderThumbnailView, info.uploaderAvatars) + binding.detailUploaderThumbnailView.visibility = View.VISIBLE + } + + fun openDownloadDialog() { + val info = currentInfo ?: return + + try { + val downloadDialog = DownloadDialog(activity, info) + downloadDialog.show(activity.supportFragmentManager, "downloadDialog") + } catch (e: Exception) { + showSnackbar( + activity, + ErrorInfo(e, UserAction.DOWNLOAD_OPEN_DIALOG, "Showing download dialog", info) + ) + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Stream Results + ////////////////////////////////////////////////////////////////////////// */ + private fun checkUpdateProgressInfo(info: StreamInfo) { + positionSubscriber?.dispose() + if (!DependentPreferenceHelper.getResumePlaybackEnabled(activity)) { + binding.positionView.visibility = View.GONE + binding.detailPositionView.visibility = View.GONE + return + } + val recordManager = HistoryRecordManager(requireContext()) + positionSubscriber = recordManager.loadStreamState(info) + .subscribeOn(Schedulers.io()) + .onErrorComplete() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { state -> updatePlaybackProgress(state.progressMillis, info.duration * 1000) }, + { throwable -> /* impossible due to the onErrorComplete() */ }, + { /* onComplete */ + binding.positionView.visibility = View.GONE + binding.detailPositionView.visibility = View.GONE + } + ) + } + + private fun updatePlaybackProgress(progress: Long, duration: Long) { + if (!DependentPreferenceHelper.getResumePlaybackEnabled(activity)) { + return + } + val progressSeconds = TimeUnit.MILLISECONDS.toSeconds(progress).toInt() + val durationSeconds = TimeUnit.MILLISECONDS.toSeconds(duration).toInt() + binding.positionView.setMax(durationSeconds) + // If the old and the new progress values have a big difference then use animation. + // Otherwise don't because it affects CPU + if (abs(binding.positionView.progress - progressSeconds) > 2) { + binding.positionView.setProgressAnimated(progressSeconds) + } else { + binding.positionView.progress = progressSeconds + } + val position = Localization.getDurationString(progressSeconds.toLong()) + if (position != binding.detailPositionView.getText()) { + binding.detailPositionView.text = position + } + if (binding.positionView.visibility != View.VISIBLE) { + binding.positionView.animate(true, 100) + binding.detailPositionView.animate(true, 100) + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Player event listener + ////////////////////////////////////////////////////////////////////////// */ + override fun onViewCreated() { + tryAddVideoPlayerView() + } + + override fun onQueueUpdate(queue: PlayQueue) { + playQueue = queue + if (DEBUG) { + Log.d( + TAG, + "onQueueUpdate() called with: serviceId = [$serviceId], url = [${ + url}], name = [$title], playQueue = [$playQueue]" + ) + } + + // Register broadcast receiver to listen to playQueue changes + // and hide the overlayPlayQueueButton when the playQueue is empty / destroyed. + playQueue?.broadcastReceiver?.subscribe { updateOverlayPlayQueueButtonVisibility() } + ?.let { disposables.add(it) } + + // This should be the only place where we push data to stack. + // It will allow to have live instance of PlayQueue with actual information about + // deleted/added items inside Channel/Playlist queue and makes possible to have + // a history of played items + if (stack.peek()?.playQueue?.equals(queue) == false) { + queue.item?.let { queueItem -> + stack.push(StackItem(queueItem.serviceId, queueItem.url, queueItem.title, queue)) + return@onQueueUpdate + } // if queue.item == null continue below + } + + // On every MainPlayer service's destroy() playQueue gets disposed and + // no longer able to track progress. That's why we update our cached disposed + // queue with the new one that is active and have the same history. + // Without that the cached playQueue will have an old recovery position + findQueueInStack(queue)?.playQueue = queue + } + + override fun onPlaybackUpdate( + state: Int, + repeatMode: Int, + shuffled: Boolean, + parameters: PlaybackParameters? + ) { + setOverlayPlayPauseImage(player?.isPlaying == true) + + if (state == Player.STATE_PLAYING && binding.positionView.alpha != 1.0f && + player?.playQueue?.item?.url?.equals(url) == true + ) { + binding.positionView.animate(true, 100) + binding.detailPositionView.animate(true, 100) + } + } + + override fun onProgressUpdate( + currentProgress: Int, + duration: Int, + bufferPercent: Int + ) { + // Progress updates are received every second even if media is paused. It's useless until + // playing, hence the `player?.isPlaying == true` check. + if (player?.isPlaying == true && player?.playQueue?.item?.url?.equals(url) == true) { + updatePlaybackProgress(currentProgress.toLong(), duration.toLong()) + } + } + + override fun onMetadataUpdate(info: StreamInfo, queue: PlayQueue) { + findQueueInStack(queue)?.let { item -> + // When PlayQueue can have multiple streams (PlaylistPlayQueue or ChannelPlayQueue) + // every new played stream gives new title and url. + // StackItem contains information about first played stream. Let's update it here + item.title = info.name + item.url = info.url + } + // They are not equal when user watches something in popup while browsing in fragment and + // then changes screen orientation. In that case the fragment will set itself as + // a service listener and will receive initial call to onMetadataUpdate() + if (queue != playQueue) { + return + } + + updateOverlayData(info.name, info.uploaderName, info.thumbnails) + if (info.url == currentInfo?.url) { + return + } + + currentInfo = info + setInitialData(info.serviceId, info.url, info.name, queue) + setAutoPlay(false) + // Delay execution just because it freezes the main thread, and while playing + // next/previous video you see visual glitches + // (when non-vertical video goes after vertical video) + prepareAndHandleInfoIfNeededAfterDelay(info, true, 200) + } + + override fun onPlayerError(error: PlaybackException?, isCatchableException: Boolean) { + if (!isCatchableException) { + // Properly exit from fullscreen + toggleFullscreenIfInFullscreenMode() + hideMainPlayerOnLoadingNewStream() + } + } + + override fun onServiceStopped() { + // the binding could be null at this point, if the app is finishing + if (nullableBinding != null) { + setOverlayPlayPauseImage(false) + currentInfo?.let { updateOverlayData(it.name, it.uploaderName, it.thumbnails) } + updateOverlayPlayQueueButtonVisibility() + } + } + + override fun onFullscreenStateChanged(fullscreen: Boolean) { + setupBrightness() + if (playerService == null || + player?.UIs()?.get(MainPlayerUi::class) == null || + this.root?.parent == null + ) { + return + } + + if (fullscreen) { + hideSystemUiIfNeeded() + binding.overlayPlayPauseButton.requestFocus() + } else { + showSystemUi() + } + + binding.relatedItemsLayout?.isVisible = !fullscreen + scrollToTop() + + tryAddVideoPlayerView() + } + + override fun onScreenRotationButtonClicked() { + // In tablet user experience will be better if screen will not be rotated + // from landscape to portrait every time. + // Just turn on fullscreen mode in landscape orientation + // or portrait & unlocked global orientation + val isLandscape = DeviceUtils.isLandscape(requireContext()) + if (DeviceUtils.isTablet(activity) && + (!PlayerHelper.globalScreenOrientationLocked(activity) || isLandscape) + ) { + player!!.UIs().get(MainPlayerUi::class)?.toggleFullscreen() + return + } + + val newOrientation = if (isLandscape) + ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + else + ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + + activity.setRequestedOrientation(newOrientation) + } + + /* + * Will scroll down to description view after long click on moreOptionsButton + * */ + override fun onMoreOptionsLongClicked() { + val params = binding.appBarLayout.layoutParams as CoordinatorLayout.LayoutParams + val behavior = params.behavior as AppBarLayout.Behavior + val valueAnimator = ValueAnimator.ofInt(0, -binding.playerPlaceholder.height) + valueAnimator.addUpdateListener { animation -> + behavior.setTopAndBottomOffset(animation.getAnimatedValue() as Int) + binding.appBarLayout.requestLayout() + } + valueAnimator.interpolator = DecelerateInterpolator() + valueAnimator.duration = 500 + valueAnimator.start() + } + + /*////////////////////////////////////////////////////////////////////////// + // Player related utils + ////////////////////////////////////////////////////////////////////////// */ + private fun showSystemUi() { + if (DEBUG) { + Log.d(TAG, "showSystemUi() called") + } + + if (activity == null) { + return + } + + // Prevent jumping of the player on devices with cutout + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + activity.window.attributes.layoutInDisplayCutoutMode = + WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT + } + activity.window.decorView.systemUiVisibility = 0 + activity.window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) + activity.window.statusBarColor = ThemeHelper.resolveColorFromAttr( + requireContext(), android.R.attr.colorPrimary + ) + } + + private fun hideSystemUi() { + if (DEBUG) { + Log.d(TAG, "hideSystemUi() called") + } + + if (activity == null) { + return + } + + // Prevent jumping of the player on devices with cutout + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + activity.window.attributes.layoutInDisplayCutoutMode = + WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES + } + var visibility = ( + View.SYSTEM_UI_FLAG_LAYOUT_STABLE + or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + ) + + // In multiWindow mode status bar is not transparent for devices with cutout + // if I include this flag. So without it is better in this case + val isInMultiWindow = DeviceUtils.isInMultiWindow(activity) + if (!isInMultiWindow) { + visibility = visibility or View.SYSTEM_UI_FLAG_FULLSCREEN + } + activity.window.decorView.systemUiVisibility = visibility + + if (isInMultiWindow || this.isFullscreen) { + activity.window.statusBarColor = Color.TRANSPARENT + activity.window.navigationBarColor = Color.TRANSPARENT + } + activity.window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) + } + + // Listener implementation + override fun hideSystemUiIfNeeded() { + if (this.isFullscreen && + bottomSheetBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED + ) { + hideSystemUi() + } + } + + private val isFullscreen: Boolean + get() = player?.UIs()?.get(VideoPlayerUi::class)?.isFullscreen == true + + /** + * @return true if the player is null, or if the player is nonnull but is stopped. + */ + @Suppress("NullableBooleanElvis") // rewriting as "!= false" creates more confusion + private val playerIsStopped + get() = player?.isStopped ?: true + + private fun restoreDefaultBrightness() { + val lp = activity.window.attributes + if (lp.screenBrightness == -1f) { + return + } + + // Restore the old brightness when fragment.onPause() called or + // when a player is in portrait + lp.screenBrightness = -1f + activity.window.setAttributes(lp) + } + + private fun setupBrightness() { + if (activity == null) { + return + } + + val lp = activity.window.attributes + if (!this.isFullscreen || bottomSheetState != BottomSheetBehavior.STATE_EXPANDED) { + // Apply system brightness when the player is not in fullscreen + restoreDefaultBrightness() + } else { + // Do not restore if user has disabled brightness gesture + val brightnessControlKey = getString(R.string.brightness_control_key) + if (PlayerHelper.getActionForRightGestureSide(activity) != brightnessControlKey && + PlayerHelper.getActionForLeftGestureSide(activity) != brightnessControlKey + ) { + return + } + // Restore already saved brightness level + val brightnessLevel = PlayerHelper.getScreenBrightness(activity) + if (brightnessLevel == lp.screenBrightness) { + return + } + lp.screenBrightness = brightnessLevel + activity.window.setAttributes(lp) + } + } + + /** + * Make changes to the UI to accommodate for better usability on bigger screens such as TVs + * or in Android's desktop mode (DeX etc). + */ + private fun accommodateForTvAndDesktopMode() { + if (DeviceUtils.isTv(context)) { + // remove ripple effects from detail controls + val transparent = ContextCompat.getColor( + requireContext(), + R.color.transparent_background_color + ) + binding.detailControlsPlaylistAppend.setBackgroundColor(transparent) + binding.detailControlsBackground.setBackgroundColor(transparent) + binding.detailControlsPopup.setBackgroundColor(transparent) + binding.detailControlsDownload.setBackgroundColor(transparent) + binding.detailControlsShare.setBackgroundColor(transparent) + binding.detailControlsOpenInBrowser.setBackgroundColor(transparent) + binding.detailControlsPlayWithKodi.setBackgroundColor(transparent) + } + if (DeviceUtils.isDesktopMode(requireContext())) { + // Remove the "hover" overlay (since it is visible on all mouse events and interferes + // with the video content being played) + binding.detailThumbnailRootLayout.setForeground(null) + } + } + + private fun checkLandscape() { + if ((!player!!.isPlaying && player!!.playQueue !== playQueue) || + player!!.playQueue == null + ) { + setAutoPlay(true) + } + + player!!.UIs().get(MainPlayerUi::class)?.checkLandscape() + // Let's give a user time to look at video information page if video is not playing + if (PlayerHelper.globalScreenOrientationLocked(activity) && !player!!.isPlaying) { + player!!.play() + } + } + + /* + * Means that the player fragment was swiped away via BottomSheetLayout + * and is empty but ready for any new actions. See cleanUp() + * */ + private fun wasCleared(): Boolean { + return url == null + } + + private fun findQueueInStack(queue: PlayQueue): StackItem? { + return stack.descendingIterator().asSequence() + .firstOrNull { it?.playQueue?.equals(queue) == true } + } + + private fun replaceQueueIfUserConfirms(onAllow: Runnable) { + // Player will have STATE_IDLE when a user pressed back button + if (PlayerHelper.isClearingQueueConfirmationRequired(activity) && + !playerIsStopped && player?.playQueue != playQueue + ) { + showClearingQueueConfirmation(onAllow) + } else { + onAllow.run() + } + } + + private fun showClearingQueueConfirmation(onAllow: Runnable) { + AlertDialog.Builder(activity) + .setTitle(R.string.clear_queue_confirmation_description) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.ok) { dialog, which -> + onAllow.run() + dialog?.dismiss() + } + .show() + } + + private fun showExternalVideoPlaybackDialog() { + val info = currentInfo ?: return + + val builder = AlertDialog.Builder(activity) + builder.setTitle(R.string.select_quality_external_players) + builder.setNeutralButton(R.string.open_in_browser) { dialog, which -> + ShareUtils.openUrlInBrowser(requireActivity(), url) + } + + val videoStreamsForExternalPlayers = ListHelper.getSortedStreamVideosList( + activity, + ListHelper.getUrlAndNonTorrentStreams(info.videoStreams), + ListHelper.getUrlAndNonTorrentStreams(info.videoOnlyStreams), + false, + false + ) + + if (videoStreamsForExternalPlayers.isEmpty()) { + builder.setMessage(R.string.no_video_streams_available_for_external_players) + builder.setPositiveButton(R.string.ok, null) + } else { + val selectedVideoStreamIndexForExternalPlayers = + ListHelper.getDefaultResolutionIndex(activity, videoStreamsForExternalPlayers) + val resolutions = videoStreamsForExternalPlayers + .map { it.getResolution() as CharSequence } + .toTypedArray() + + builder + .setSingleChoiceItems(resolutions, selectedVideoStreamIndexForExternalPlayers, null) + builder.setNegativeButton(R.string.cancel, null) + builder.setPositiveButton(R.string.ok) { dialog, which -> + val index = (dialog as AlertDialog).listView.getCheckedItemPosition() + // We don't have to manage the index validity because if there is no stream + // available for external players, this code will be not executed and if there is + // no stream which matches the default resolution, 0 is returned by + // ListHelper.getDefaultResolutionIndex. + // The index cannot be outside the bounds of the list as its always between 0 and + // the list size - 1, . + startOnExternalPlayer(activity, info, videoStreamsForExternalPlayers[index]) + } + } + builder.show() + } + + private fun showExternalAudioPlaybackDialog() { + val info = currentInfo ?: return + + val audioStreams = ListHelper.getUrlAndNonTorrentStreams(info.audioStreams) + val audioTracks = ListHelper.getFilteredAudioStreams(activity, audioStreams) + + if (audioTracks.isEmpty()) { + Toast.makeText( + activity, R.string.no_audio_streams_available_for_external_players, + Toast.LENGTH_SHORT + ).show() + } else if (audioTracks.size == 1) { + startOnExternalPlayer(activity, info, audioTracks[0]) + } else { + val selectedAudioStream = ListHelper.getDefaultAudioFormat(activity, audioTracks) + val trackNames = audioTracks.map { Localization.audioTrackName(activity, it) } + + AlertDialog.Builder(activity) + .setTitle(R.string.select_audio_track_external_players) + .setNeutralButton(R.string.open_in_browser) { dialog, which -> + ShareUtils.openUrlInBrowser(requireActivity(), url) + } + .setSingleChoiceItems(trackNames.toTypedArray(), selectedAudioStream, null) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.ok) { dialog, which -> + val index = (dialog as AlertDialog).listView.getCheckedItemPosition() + startOnExternalPlayer(activity, info, audioTracks[index]) + } + .show() + } + } + + /* + * Remove unneeded information while waiting for a next task + * */ + private fun cleanUp() { + // New beginning + stack.clear() + currentWorker?.dispose() + PlayerHolder.stopService() + setInitialData(0, null, "", null) + currentInfo = null + updateOverlayData(null, null, listOf()) + } + + /*////////////////////////////////////////////////////////////////////////// + // Bottom mini player + ////////////////////////////////////////////////////////////////////////// */ + /** + * That's for Android TV support. Move focus from main fragment to the player or back + * based on what is currently selected + * + * @param toMain if true than the main fragment will be focused or the player otherwise + */ + private fun moveFocusToMainFragment(toMain: Boolean) { + setupBrightness() + val mainFragment = requireActivity().findViewById(R.id.fragment_holder) + // Hamburger button steels a focus even under bottomSheet + val toolbar = requireActivity().findViewById(R.id.toolbar) + val afterDescendants = ViewGroup.FOCUS_AFTER_DESCENDANTS + val blockDescendants = ViewGroup.FOCUS_BLOCK_DESCENDANTS + if (toMain) { + mainFragment.setDescendantFocusability(afterDescendants) + toolbar.setDescendantFocusability(afterDescendants) + (requireView() as ViewGroup).setDescendantFocusability(blockDescendants) + // Only focus the mainFragment if the mainFragment (e.g. search-results) + // or the toolbar (e.g. TextField for search) don't have focus. + // This was done to fix problems with the keyboard input, see also #7490 + if (!mainFragment.hasFocus() && !toolbar.hasFocus()) { + mainFragment.requestFocus() + } + } else { + mainFragment.setDescendantFocusability(blockDescendants) + toolbar.setDescendantFocusability(blockDescendants) + (requireView() as ViewGroup).setDescendantFocusability(afterDescendants) + // Only focus the player if it not already has focus + if (!binding.getRoot().hasFocus()) { + binding.detailThumbnailRootLayout.requestFocus() + } + } + } + + /** + * When the mini player exists the view underneath it is not touchable. + * Bottom padding should be equal to the mini player's height in this case + * + * @param showMore whether main fragment should be expanded or not + */ + private fun manageSpaceAtTheBottom(showMore: Boolean) { + val peekHeight = resources.getDimensionPixelSize(R.dimen.mini_player_height) + val holder = requireActivity().findViewById(R.id.fragment_holder) + val newBottomPadding = if (showMore) 0 else peekHeight + if (holder.paddingBottom == newBottomPadding) { + return + } + holder.setPadding( + holder.getPaddingLeft(), + holder.paddingTop, + holder.getPaddingRight(), + newBottomPadding + ) + } + + private fun setupBottomPlayer() { + val params = binding.appBarLayout.layoutParams as CoordinatorLayout.LayoutParams + val behavior = params.behavior as AppBarLayout.Behavior? + + val bottomSheetLayout = activity.findViewById(R.id.fragment_player_holder) + bottomSheetBehavior = BottomSheetBehavior.from(bottomSheetLayout) + bottomSheetBehavior.state = lastStableBottomSheetState + updateBottomSheetState(lastStableBottomSheetState) + + val peekHeight = resources.getDimensionPixelSize(R.dimen.mini_player_height) + if (bottomSheetState != BottomSheetBehavior.STATE_HIDDEN) { + manageSpaceAtTheBottom(false) + bottomSheetBehavior.peekHeight = peekHeight + if (bottomSheetState == BottomSheetBehavior.STATE_COLLAPSED) { + binding.overlayLayout.alpha = MAX_OVERLAY_ALPHA + } else if (bottomSheetState == BottomSheetBehavior.STATE_EXPANDED) { + binding.overlayLayout.alpha = 0f + setOverlayElementsClickable(false) + } + } + + bottomSheetCallback = object : BottomSheetCallback() { + override fun onStateChanged(bottomSheet: View, newState: Int) { + updateBottomSheetState(newState) + + when (newState) { + BottomSheetBehavior.STATE_HIDDEN -> { + moveFocusToMainFragment(true) + manageSpaceAtTheBottom(true) + + bottomSheetBehavior.peekHeight = 0 + cleanUp() + } + + BottomSheetBehavior.STATE_EXPANDED -> { + moveFocusToMainFragment(false) + manageSpaceAtTheBottom(false) + + bottomSheetBehavior.peekHeight = peekHeight + // Disable click because overlay buttons located on top of buttons + // from the player + setOverlayElementsClickable(false) + hideSystemUiIfNeeded() + // Conditions when the player should be expanded to fullscreen + if (DeviceUtils.isLandscape(requireContext()) && + player?.isPlaying == true && + !this@VideoDetailFragment.isFullscreen && + !DeviceUtils.isTablet(activity) + ) { + player?.UIs()?.get(MainPlayerUi::class)?.toggleFullscreen() + } + setOverlayLook(binding.appBarLayout, behavior, 1f) + } + + BottomSheetBehavior.STATE_COLLAPSED -> { + moveFocusToMainFragment(true) + manageSpaceAtTheBottom(false) + + bottomSheetBehavior.peekHeight = peekHeight + + // Re-enable clicks + setOverlayElementsClickable(true) + player?.UIs()?.get(MainPlayerUi::class)?.closeItemsList() + setOverlayLook(binding.appBarLayout, behavior, 0f) + } + + BottomSheetBehavior.STATE_DRAGGING, BottomSheetBehavior.STATE_SETTLING -> { + if (this@VideoDetailFragment.isFullscreen) { + showSystemUi() + } + player?.UIs()?.get(MainPlayerUi::class)?.let { + if (it.isControlsVisible) { + it.hideControls(0, 0) + } + } + } + + BottomSheetBehavior.STATE_HALF_EXPANDED -> {} + } + } + + override fun onSlide(bottomSheet: View, slideOffset: Float) { + setOverlayLook(binding.appBarLayout, behavior, slideOffset) + } + } + + bottomSheetBehavior.addBottomSheetCallback(bottomSheetCallback) + + // User opened a new page and the player will hide itself + activity.supportFragmentManager.addOnBackStackChangedListener { + if (bottomSheetBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED) { + bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED) + } + } + } + + private fun updateOverlayPlayQueueButtonVisibility() { + // hide the button if the queue is empty; no player => no play queue :) + nullableBinding?.overlayPlayQueueButton?.isVisible = player?.playQueue?.isEmpty == false + } + + private fun updateOverlayData( + overlayTitle: String?, + uploader: String?, + thumbnails: List + ) { + binding.overlayTitleTextView.text = overlayTitle ?: "" + binding.overlayChannelTextView.text = uploader ?: "" + binding.overlayThumbnail.setImageDrawable(null) + CoilHelper.loadDetailsThumbnail(binding.overlayThumbnail, thumbnails) + } + + private fun setOverlayPlayPauseImage(playerIsPlaying: Boolean) { + val drawable = if (playerIsPlaying) R.drawable.ic_pause else R.drawable.ic_play_arrow + binding.overlayPlayPauseButton.setImageResource(drawable) + } + + private fun setOverlayLook( + appBar: AppBarLayout, + behavior: AppBarLayout.Behavior?, + slideOffset: Float + ) { + // SlideOffset < 0 when mini player is about to close via swipe. + // Stop animation in this case + if (behavior == null || slideOffset < 0) { + return + } + binding.overlayLayout.alpha = min(MAX_OVERLAY_ALPHA, 1 - slideOffset) + // These numbers are not special. They just do a cool transition + behavior.setTopAndBottomOffset( + (-binding.detailThumbnailImageView.height * 2 * (1 - slideOffset) / 3).toInt() + ) + appBar.requestLayout() + } + + private fun setOverlayElementsClickable(enable: Boolean) { + binding.overlayThumbnail.isClickable = enable + binding.overlayThumbnail.isLongClickable = enable + binding.overlayMetadataLayout.isClickable = enable + binding.overlayMetadataLayout.isLongClickable = enable + binding.overlayButtonsLayout.isClickable = enable + binding.overlayPlayQueueButton.isClickable = enable + binding.overlayPlayPauseButton.isClickable = enable + binding.overlayCloseButton.isClickable = enable + } + + val root: View? + get() = player?.UIs()?.get(VideoPlayerUi::class)?.binding?.root + + private fun updateBottomSheetState(newState: Int) { + bottomSheetState = newState + if (newState != BottomSheetBehavior.STATE_DRAGGING && + newState != BottomSheetBehavior.STATE_SETTLING + ) { + lastStableBottomSheetState = newState + } + } + + companion object { + const val KEY_SWITCHING_PLAYERS: String = "switching_players" + + private const val MAX_OVERLAY_ALPHA = 0.9f + private const val MAX_PLAYER_HEIGHT = 0.7f + + const val ACTION_SHOW_MAIN_PLAYER: String = + App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_SHOW_MAIN_PLAYER" + const val ACTION_HIDE_MAIN_PLAYER: String = + App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_HIDE_MAIN_PLAYER" + const val ACTION_PLAYER_STARTED: String = + App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_PLAYER_STARTED" + const val ACTION_VIDEO_FRAGMENT_RESUMED: String = + App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_VIDEO_FRAGMENT_RESUMED" + const val ACTION_VIDEO_FRAGMENT_STOPPED: String = + App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_VIDEO_FRAGMENT_STOPPED" + + private const val COMMENTS_TAB_TAG = "COMMENTS" + private const val RELATED_TAB_TAG = "NEXT VIDEO" + private const val DESCRIPTION_TAB_TAG = "DESCRIPTION TAB" + private const val EMPTY_TAB_TAG = "EMPTY TAB" + + /*//////////////////////////////////////////////////////////////////////// */ + @JvmStatic + fun getInstance( + serviceId: Int, + url: String?, + name: String, + queue: PlayQueue? + ): VideoDetailFragment { + val instance = VideoDetailFragment() + instance.setInitialData(serviceId, url, name, queue) + return instance + } + + @JvmStatic + fun getInstanceInCollapsedState(): VideoDetailFragment { + val instance = VideoDetailFragment() + instance.updateBottomSheetState(BottomSheetBehavior.STATE_COLLAPSED) + return instance + } + + /*////////////////////////////////////////////////////////////////////////// + // OwnStack + ////////////////////////////////////////////////////////////////////////// */ + /** + * Stack that contains the "navigation history".

+ * The peek is the current video. + */ + private var stack = LinkedList() + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.java index 61a361f23..7f594734a 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.java @@ -8,6 +8,7 @@ import android.util.Log; import android.view.View; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import com.evernote.android.state.State; @@ -42,6 +43,7 @@ public abstract class BaseListInfoFragment menuItemToFilterName = new SparseArrayCompat<>(); private StreamingService service; + @Nullable private Page nextPage; private boolean showLocalSuggestions = true; private boolean showRemoteSuggestions = true; @@ -221,6 +222,15 @@ public class SearchFragment extends BaseListFragment result) { showListFooter(false); infoListAdapter.addInfoItemList(result.getItems()); - nextPage = result.getNextPage(); if (!result.getErrors().isEmpty()) { - showSnackBarError(new ErrorInfo(result.getErrors(), UserAction.SEARCHED, - "\"" + searchString + "\" → pageUrl: " + nextPage.getUrl() + ", " - + "pageIds: " + nextPage.getIds() + ", " - + "pageCookies: " + nextPage.getCookies(), - serviceId)); + // nextPage should be non-null at this point, because it refers to the page + // whose results are handled here, but let's check it anyway + if (nextPage == null) { + showSnackBarError(new ErrorInfo(result.getErrors(), UserAction.SEARCHED, + "\"" + searchString + "\" → nextPage == null", serviceId)); + } else { + showSnackBarError(new ErrorInfo(result.getErrors(), UserAction.SEARCHED, + "\"" + searchString + "\" → pageUrl: " + nextPage.getUrl() + ", " + + "pageIds: " + nextPage.getIds() + ", " + + "pageCookies: " + nextPage.getCookies(), + serviceId)); + } } + + // keep the reassignment of nextPage after the error handling to ensure that nextPage + // still holds the correct value during the error handling + nextPage = result.getNextPage(); super.handleNextItems(result); } diff --git a/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java b/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java index dcf01e190..cbaae2834 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java @@ -252,7 +252,7 @@ public final class InfoItemDialog { * @return the current {@link Builder} instance */ public Builder addEnqueueEntriesIfNeeded() { - final PlayerHolder holder = PlayerHolder.getInstance(); + final PlayerHolder holder = PlayerHolder.INSTANCE; if (holder.isPlayQueueReady()) { addEntry(StreamDialogDefaultEntry.ENQUEUE); diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadService.kt b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadService.kt index f960040de..4aa825ca8 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadService.kt +++ b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadService.kt @@ -31,6 +31,7 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.PendingIntentCompat import androidx.core.app.ServiceCompat +import androidx.core.content.ContextCompat import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.disposables.Disposable @@ -200,7 +201,7 @@ class FeedLoadService : Service() { } } } - registerReceiver(broadcastReceiver, IntentFilter(ACTION_CANCEL)) + ContextCompat.registerReceiver(this, broadcastReceiver, IntentFilter(ACTION_CANCEL), ContextCompat.RECEIVER_NOT_EXPORTED) } // ///////////////////////////////////////////////////////////////////////// diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/ImportConfirmationDialog.java b/app/src/main/java/org/schabi/newpipe/local/subscription/ImportConfirmationDialog.java index a4b3ea399..3dc6d7b46 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/ImportConfirmationDialog.java +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/ImportConfirmationDialog.java @@ -1,7 +1,5 @@ package org.schabi.newpipe.local.subscription; -import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; - import android.app.Dialog; import android.os.Bundle; @@ -39,7 +37,6 @@ public class ImportConfirmationDialog extends DialogFragment { @Override public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) { final var context = requireContext(); - assureCorrectAppLanguage(context); return new AlertDialog.Builder(context) .setMessage(R.string.import_network_expensive_warning) .setCancelable(true) diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java b/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java index 9d680da4d..7f3a8dbd5 100644 --- a/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java +++ b/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java @@ -2,7 +2,6 @@ package org.schabi.newpipe.player; import static org.schabi.newpipe.QueueItemMenuUtil.openPopupMenu; import static org.schabi.newpipe.player.helper.PlayerHelper.formatSpeed; -import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; import android.content.ComponentName; import android.content.Intent; @@ -84,7 +83,6 @@ public final class PlayQueueActivity extends AppCompatActivity @Override protected void onCreate(final Bundle savedInstanceState) { - assureCorrectAppLanguage(this); super.onCreate(savedInstanceState); ThemeHelper.setTheme(this, ServiceHelper.getSelectedServiceId(this)); @@ -97,8 +95,48 @@ public final class PlayQueueActivity extends AppCompatActivity getSupportActionBar().setTitle(R.string.title_activity_play_queue); } - serviceConnection = getServiceConnection(); - bind(); + serviceConnection = new ServiceConnection() { + @Override + public void onServiceDisconnected(final ComponentName name) { + Log.d(TAG, "Player service is disconnected"); + } + + @Override + public void onServiceConnected(final ComponentName name, final IBinder binder) { + Log.d(TAG, "Player service is connected"); + + if (binder instanceof PlayerService.LocalBinder) { + @Nullable final PlayerService s = + ((PlayerService.LocalBinder) binder).getService(); + if (s == null) { + throw new IllegalArgumentException( + "PlayerService.LocalBinder.getService() must never be" + + "null after the service connects"); + } + player = s.getPlayer(); + } + + if (player == null || player.getPlayQueue() == null || player.exoPlayerIsNull()) { + unbind(); + } else { + onQueueUpdate(player.getPlayQueue()); + buildComponents(); + if (player != null) { + player.setActivityListener(PlayQueueActivity.this); + } + } + } + }; + + // Note: this code should not really exist, and PlayerHolder should be used instead, but + // it will be rewritten when NewPlayer will replace the current player. + final Intent bindIntent = new Intent(this, PlayerService.class); + bindIntent.setAction(PlayerService.BIND_PLAYER_HOLDER_ACTION); + final boolean success = bindService(bindIntent, serviceConnection, BIND_AUTO_CREATE); + if (!success) { + unbindService(serviceConnection); + } + serviceBound = success; } @Override @@ -180,19 +218,6 @@ public final class PlayQueueActivity extends AppCompatActivity //////////////////////////////////////////////////////////////////////////// // Service Connection - //////////////////////////////////////////////////////////////////////////// - - private void bind() { - // Note: this code should not really exist, and PlayerHolder should be used instead, but - // it will be rewritten when NewPlayer will replace the current player. - final Intent bindIntent = new Intent(this, PlayerService.class); - bindIntent.setAction(PlayerService.BIND_PLAYER_HOLDER_ACTION); - final boolean success = bindService(bindIntent, serviceConnection, BIND_AUTO_CREATE); - if (!success) { - unbindService(serviceConnection); - } - serviceBound = success; - } private void unbind() { if (serviceBound) { @@ -212,41 +237,6 @@ public final class PlayQueueActivity extends AppCompatActivity } } - private ServiceConnection getServiceConnection() { - return new ServiceConnection() { - @Override - public void onServiceDisconnected(final ComponentName name) { - Log.d(TAG, "Player service is disconnected"); - } - - @Override - public void onServiceConnected(final ComponentName name, final IBinder binder) { - Log.d(TAG, "Player service is connected"); - - if (binder instanceof PlayerService.LocalBinder) { - @Nullable final PlayerService s = - ((PlayerService.LocalBinder) binder).getService(); - if (s == null) { - throw new IllegalArgumentException( - "PlayerService.LocalBinder.getService() must never be" - + "null after the service connects"); - } - player = s.getPlayer(); - } - - if (player == null || player.getPlayQueue() == null || player.exoPlayerIsNull()) { - unbind(); - } else { - onQueueUpdate(player.getPlayQueue()); - buildComponents(); - if (player != null) { - player.setActivityListener(PlayQueueActivity.this); - } - } - } - }; - } - //////////////////////////////////////////////////////////////////////////// // Component Building //////////////////////////////////////////////////////////////////////////// diff --git a/app/src/main/java/org/schabi/newpipe/player/Player.java b/app/src/main/java/org/schabi/newpipe/player/Player.java index d3e3ff1df..b09593c17 100644 --- a/app/src/main/java/org/schabi/newpipe/player/Player.java +++ b/app/src/main/java/org/schabi/newpipe/player/Player.java @@ -44,7 +44,6 @@ import static org.schabi.newpipe.player.notification.NotificationConstants.ACTIO import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_SHUFFLE; import static org.schabi.newpipe.util.ListHelper.getPopupResolutionIndex; import static org.schabi.newpipe.util.ListHelper.getResolutionIndex; -import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; import static java.util.concurrent.TimeUnit.MILLISECONDS; import static coil3.Image_androidKt.toBitmap; @@ -61,6 +60,7 @@ import android.view.LayoutInflater; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; import androidx.core.math.MathUtils; import androidx.preference.PreferenceManager; @@ -133,6 +133,10 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.disposables.SerialDisposable; +/** + * The ExoPlayer wrapper & Player business logic. + * Only instantiated once, from {@link PlayerService}. + */ public final class Player implements PlaybackListener, Listener { public static final boolean DEBUG = MainActivity.DEBUG; public static final String TAG = Player.class.getSimpleName(); @@ -397,7 +401,7 @@ public final class Player implements PlaybackListener, Listener { && newQueue.size() == 1 && newQueue.getItem() != null && playQueue != null && playQueue.size() == 1 && playQueue.getItem() != null && newQueue.getItem().getUrl().equals(playQueue.getItem().getUrl()) - && newQueue.getItem().getRecoveryPosition() != PlayQueueItem.RECOVERY_UNSET) { + && newQueue.getItem().getRecoveryPosition() != Long.MIN_VALUE) { // Player can have state = IDLE when playback is stopped or failed // and we should retry in this case if (simpleExoPlayer.getPlaybackState() @@ -425,7 +429,7 @@ public final class Player implements PlaybackListener, Listener { && !samePlayQueue && !newQueue.isEmpty() && newQueue.getItem() != null - && newQueue.getItem().getRecoveryPosition() == PlayQueueItem.RECOVERY_UNSET) { + && newQueue.getItem().getRecoveryPosition() == Long.MIN_VALUE) { databaseUpdateDisposable.add(recordManager.loadStreamState(newQueue.getItem()) .observeOn(AndroidSchedulers.mainThread()) // Do not place initPlayback() in doFinally() because @@ -473,22 +477,23 @@ public final class Player implements PlaybackListener, Listener { } private void initUIsForCurrentPlayerType() { - if ((UIs.getOpt(MainPlayerUi.class).isPresent() && playerType == PlayerType.MAIN) - || (UIs.getOpt(PopupPlayerUi.class).isPresent() + if ((UIs.get(MainPlayerUi.class) != null && playerType == PlayerType.MAIN) + || (UIs.get(PopupPlayerUi.class) != null && playerType == PlayerType.POPUP)) { // correct UI already in place return; } // try to reuse binding if possible - final PlayerBinding binding = UIs.getOpt(VideoPlayerUi.class).map(VideoPlayerUi::getBinding) - .orElseGet(() -> { - if (playerType == PlayerType.AUDIO) { - return null; - } else { - return PlayerBinding.inflate(LayoutInflater.from(context)); - } - }); + @Nullable final VideoPlayerUi ui = UIs.get(VideoPlayerUi.class); + final PlayerBinding binding; + if (ui != null) { + binding = ui.getBinding(); + } else if (playerType == PlayerType.AUDIO) { + binding = null; + } else { + binding = PlayerBinding.inflate(LayoutInflater.from(context)); + } switch (playerType) { case MAIN: @@ -751,7 +756,6 @@ public final class Player implements PlaybackListener, Listener { toggleShuffleModeEnabled(); break; case Intent.ACTION_CONFIGURATION_CHANGED: - assureCorrectAppLanguage(service); if (DEBUG) { Log.d(TAG, "ACTION_CONFIGURATION_CHANGED received"); } @@ -764,7 +768,8 @@ public final class Player implements PlaybackListener, Listener { private void registerBroadcastReceiver() { // Try to unregister current first unregisterBroadcastReceiver(); - context.registerReceiver(broadcastReceiver, intentFilter); + ContextCompat.registerReceiver(context, broadcastReceiver, intentFilter, + ContextCompat.RECEIVER_EXPORTED); } private void unregisterBroadcastReceiver() { @@ -1588,7 +1593,7 @@ public final class Player implements PlaybackListener, Listener { } // sync the player index with the queue index, and seek to the correct position - if (item.getRecoveryPosition() != PlayQueueItem.RECOVERY_UNSET) { + if (item.getRecoveryPosition() != Long.MIN_VALUE) { simpleExoPlayer.seekTo(playQueueIndex, item.getRecoveryPosition()); playQueue.unsetRecovery(playQueueIndex); } else { diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt b/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt index c335611b0..dc7bdaa92 100644 --- a/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt +++ b/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt @@ -34,10 +34,8 @@ import org.schabi.newpipe.player.mediabrowser.MediaBrowserImpl import org.schabi.newpipe.player.mediabrowser.MediaBrowserPlaybackPreparer import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi import org.schabi.newpipe.player.notification.NotificationPlayerUi -import org.schabi.newpipe.util.Localization import org.schabi.newpipe.util.ThemeHelper import java.lang.ref.WeakReference -import java.util.function.BiConsumer import java.util.function.Consumer /** @@ -47,13 +45,13 @@ class PlayerService : MediaBrowserServiceCompat() { // These objects are used to cleanly separate the Service implementation (in this file) and the // media browser and playback preparer implementations. At the moment the playback preparer is // only used in conjunction with the media browser. - private var mediaBrowserImpl: MediaBrowserImpl? = null - private var mediaBrowserPlaybackPreparer: MediaBrowserPlaybackPreparer? = null + private lateinit var mediaBrowserImpl: MediaBrowserImpl + private lateinit var mediaBrowserPlaybackPreparer: MediaBrowserPlaybackPreparer // these are instantiated in onCreate() as per // https://developer.android.com/training/cars/media#browser_workflow - private var mediaSession: MediaSessionCompat? = null - private var sessionConnector: MediaSessionConnector? = null + private lateinit var mediaSession: MediaSessionCompat + private lateinit var sessionConnector: MediaSessionConnector /** * @return the current active player instance. May be null, since the player service can outlive @@ -68,7 +66,7 @@ class PlayerService : MediaBrowserServiceCompat() { * The parameter taken by this [Consumer] can be null to indicate the player is being * stopped. */ - private var onPlayerStartedOrStopped: Consumer? = null + private var onPlayerStartedOrStopped: ((player: Player?) -> Unit)? = null //region Service lifecycle override fun onCreate() { @@ -77,17 +75,9 @@ class PlayerService : MediaBrowserServiceCompat() { if (DEBUG) { Log.d(TAG, "onCreate() called") } - Localization.assureCorrectAppLanguage(this) ThemeHelper.setTheme(this) - mediaBrowserImpl = MediaBrowserImpl( - this, - Consumer { parentId: String -> - this.notifyChildrenChanged( - parentId - ) - } - ) + mediaBrowserImpl = MediaBrowserImpl(this, this::notifyChildrenChanged) // see https://developer.android.com/training/cars/media#browser_workflow val session = MediaSessionCompat(this, "MediaSessionPlayerServ") @@ -98,17 +88,10 @@ class PlayerService : MediaBrowserServiceCompat() { connector.setMetadataDeduplicationEnabled(true) mediaBrowserPlaybackPreparer = MediaBrowserPlaybackPreparer( - this, - BiConsumer { message: String, code: Int -> - connector.setCustomErrorMessage( - message, - code - ) - }, - Runnable { connector.setCustomErrorMessage(null) }, - Consumer { playWhenReady: Boolean? -> - player?.onPrepare() - } + context = this, + setMediaSessionError = connector::setCustomErrorMessage, + clearMediaSessionError = { connector.setCustomErrorMessage(null) }, + onPrepare = { player?.onPrepare() } ) connector.setPlaybackPreparer(mediaBrowserPlaybackPreparer) @@ -125,11 +108,8 @@ class PlayerService : MediaBrowserServiceCompat() { if (DEBUG) { Log.d( TAG, - ( - "onStartCommand() called with: intent = [" + intent + - "], extras = [" + intent.extras.toDebugString() + - "], flags = [" + flags + "], startId = [" + startId + "]" - ) + "onStartCommand() called with: intent = [$intent], extras = [${ + intent.extras.toDebugString()}], flags = [$flags], startId = [$startId]" ) } @@ -140,7 +120,7 @@ class PlayerService : MediaBrowserServiceCompat() { val playerWasNull = (player == null) if (playerWasNull) { // make sure the player exists, in case the service was resumed - player = Player(this, mediaSession!!, sessionConnector!!) + player = Player(this, mediaSession, sessionConnector) } // Be sure that the player notification is set and the service is started in foreground, @@ -150,35 +130,29 @@ class PlayerService : MediaBrowserServiceCompat() { // no one already and starting the service in foreground should not create any issues. // If the service is already started in foreground, requesting it to be started // shouldn't do anything. - player!!.UIs().get(NotificationPlayerUi::class.java) - ?.createNotificationAndStartForeground() + player?.UIs()?.get(NotificationPlayerUi::class)?.createNotificationAndStartForeground() - val startedOrStopped = onPlayerStartedOrStopped - if (playerWasNull && startedOrStopped != null) { + if (playerWasNull) { // notify that a new player was created (but do it after creating the foreground // notification just to make sure we don't incur, due to slowness, in // "Context.startForegroundService() did not then call Service.startForeground()") - startedOrStopped.accept(player) + onPlayerStartedOrStopped?.invoke(player) } } val p = player - if (Intent.ACTION_MEDIA_BUTTON == intent.action && - (p == null || p.playQueue == null) - ) { - /* - No need to process media button's actions if the player is not working, otherwise - the player service would strangely start with nothing to play - Stop the service in this case, which will be removed from the foreground and its - notification cancelled in its destruction - */ + if (Intent.ACTION_MEDIA_BUTTON == intent.action && p?.playQueue == null) { + // No need to process media button's actions if the player is not working, otherwise + // the player service would strangely start with nothing to play + // Stop the service in this case, which will be removed from the foreground and its + // notification cancelled in its destruction destroyPlayerAndStopService() return START_NOT_STICKY } if (p != null) { p.handleIntent(intent) - p.UIs().get(MediaSessionPlayerUi::class.java) + p.UIs().get(MediaSessionPlayerUi::class) ?.handleMediaButtonIntent(intent) } @@ -218,22 +192,22 @@ class PlayerService : MediaBrowserServiceCompat() { cleanup() - mediaBrowserPlaybackPreparer?.dispose() - mediaSession?.release() - mediaBrowserImpl?.dispose() + mediaBrowserPlaybackPreparer.dispose() + mediaSession.release() + mediaBrowserImpl.dispose() } private fun cleanup() { val p = player if (p != null) { // notify that the player is being destroyed - onPlayerStartedOrStopped?.accept(null) + onPlayerStartedOrStopped?.invoke(null) p.saveAndShutdown() player = null } // Should already be handled by MediaSessionPlayerUi, but just to be sure. - mediaSession?.setActive(false) + mediaSession.setActive(false) // Should already be handled by NotificationUtil.cancelNotificationAndStopForeground() in // NotificationPlayerUi, but let's make sure that the foreground service is stopped. @@ -273,29 +247,27 @@ class PlayerService : MediaBrowserServiceCompat() { if (DEBUG) { Log.d( TAG, - ( - "onBind() called with: intent = [" + intent + - "], extras = [" + intent.extras.toDebugString() + "]" - ) + "onBind() called with: intent = [$intent], extras = [${ + intent.extras.toDebugString()}]" ) } - if (BIND_PLAYER_HOLDER_ACTION == intent.action) { + return if (BIND_PLAYER_HOLDER_ACTION == intent.action) { // Note that this binder might be reused multiple times while the service is alive, even // after unbind() has been called: https://stackoverflow.com/a/8794930 . - return mBinder + mBinder } else if (SERVICE_INTERFACE == intent.action) { // MediaBrowserService also uses its own binder, so for actions related to the media // browser service, pass the onBind to the superclass. - return super.onBind(intent) + super.onBind(intent) } else { // This is an unknown request, avoid returning any binder to not leak objects. - return null + null } } class LocalBinder internal constructor(playerService: PlayerService) : Binder() { - private val playerService = WeakReference(playerService) + private val playerService = WeakReference(playerService) val service: PlayerService? get() = playerService.get() @@ -307,9 +279,9 @@ class PlayerService : MediaBrowserServiceCompat() { * by the [Consumer] can be null to indicate that the player is stopping. * @param listener the listener to set or unset */ - fun setPlayerListener(listener: Consumer?) { + fun setPlayerListener(listener: ((player: Player?) -> Unit)?) { this.onPlayerStartedOrStopped = listener - listener?.accept(player) + listener?.invoke(player) } //endregion @@ -320,14 +292,14 @@ class PlayerService : MediaBrowserServiceCompat() { rootHints: Bundle? ): BrowserRoot? { // TODO check if the accessing package has permission to view data - return mediaBrowserImpl?.onGetRoot(clientPackageName, clientUid, rootHints) + return mediaBrowserImpl.onGetRoot(clientPackageName, clientUid, rootHints) } override fun onLoadChildren( parentId: String, result: Result> ) { - mediaBrowserImpl?.onLoadChildren(parentId, result) + mediaBrowserImpl.onLoadChildren(parentId, result) } override fun onSearch( @@ -335,7 +307,7 @@ class PlayerService : MediaBrowserServiceCompat() { extras: Bundle?, result: Result> ) { - mediaBrowserImpl?.onSearch(query, result) + mediaBrowserImpl.onSearch(query, result) } //endregion companion object { diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlaybackParameterDialog.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlaybackParameterDialog.java index 7e74c3848..c5d6ada4b 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlaybackParameterDialog.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlaybackParameterDialog.java @@ -2,7 +2,6 @@ package org.schabi.newpipe.player.helper; import static org.schabi.newpipe.ktx.ViewUtils.animateRotation; import static org.schabi.newpipe.player.Player.DEBUG; -import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; import static org.schabi.newpipe.util.ThemeHelper.resolveDrawable; import android.app.Dialog; @@ -145,7 +144,6 @@ public class PlaybackParameterDialog extends DialogFragment { @NonNull @Override public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) { - assureCorrectAppLanguage(getContext()); Bridge.restoreInstanceState(this, savedInstanceState); binding = DialogPlaybackParameterBinding.inflate(getLayoutInflater()); diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java index a110a80d6..266d65f36 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java @@ -33,11 +33,9 @@ import com.google.android.exoplayer2.trackselection.ExoTrackSelection; import com.google.android.exoplayer2.ui.AspectRatioFrameLayout; import com.google.android.exoplayer2.ui.AspectRatioFrameLayout.ResizeMode; import com.google.android.exoplayer2.ui.CaptionStyleCompat; -import com.google.android.exoplayer2.util.MimeTypes; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.InfoItem; -import org.schabi.newpipe.extractor.MediaFormat; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.extractor.stream.SubtitlesStream; @@ -47,13 +45,14 @@ import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueueItem; import org.schabi.newpipe.player.playqueue.SinglePlayQueue; import org.schabi.newpipe.util.ListHelper; +import org.schabi.newpipe.util.Localization; import java.lang.annotation.Retention; import java.text.DecimalFormat; +import java.text.DecimalFormatSymbols; import java.text.NumberFormat; import java.util.ArrayList; import java.util.Collections; -import java.util.Formatter; import java.util.HashSet; import java.util.List; import java.util.Locale; @@ -62,11 +61,7 @@ import java.util.Set; import java.util.concurrent.TimeUnit; public final class PlayerHelper { - private static final StringBuilder STRING_BUILDER = new StringBuilder(); - private static final Formatter STRING_FORMATTER = - new Formatter(STRING_BUILDER, Locale.getDefault()); - private static final NumberFormat SPEED_FORMATTER = new DecimalFormat("0.##x"); - private static final NumberFormat PITCH_FORMATTER = new DecimalFormat("##%"); + private static final FormattersProvider FORMATTERS_PROVIDER = new FormattersProvider(); @Retention(SOURCE) @IntDef({AUTOPLAY_TYPE_ALWAYS, AUTOPLAY_TYPE_WIFI, @@ -89,9 +84,11 @@ public final class PlayerHelper { private PlayerHelper() { } - //////////////////////////////////////////////////////////////////////////// - // Exposed helpers - //////////////////////////////////////////////////////////////////////////// + // region Exposed helpers + + public static void resetFormat() { + FORMATTERS_PROVIDER.reset(); + } @NonNull public static String getTimeString(final int milliSeconds) { @@ -100,35 +97,24 @@ public final class PlayerHelper { final int hours = (milliSeconds % 86400000) / 3600000; final int days = (milliSeconds % (86400000 * 7)) / 86400000; - STRING_BUILDER.setLength(0); - return (days > 0 - ? STRING_FORMATTER.format("%d:%02d:%02d:%02d", days, hours, minutes, seconds) - : hours > 0 - ? STRING_FORMATTER.format("%d:%02d:%02d", hours, minutes, seconds) - : STRING_FORMATTER.format("%02d:%02d", minutes, seconds) - ).toString(); + final Formatters formatters = FORMATTERS_PROVIDER.formatters(); + if (days > 0) { + return formatters.stringFormat("%d:%02d:%02d:%02d", days, hours, minutes, seconds); + } + + return hours > 0 + ? formatters.stringFormat("%d:%02d:%02d", hours, minutes, seconds) + : formatters.stringFormat("%02d:%02d", minutes, seconds); } @NonNull public static String formatSpeed(final double speed) { - return SPEED_FORMATTER.format(speed); + return FORMATTERS_PROVIDER.formatters().speed().format(speed); } @NonNull public static String formatPitch(final double pitch) { - return PITCH_FORMATTER.format(pitch); - } - - @NonNull - public static String subtitleMimeTypesOf(@NonNull final MediaFormat format) { - switch (format) { - case VTT: - return MimeTypes.TEXT_VTT; - case TTML: - return MimeTypes.APPLICATION_TTML; - default: - throw new IllegalArgumentException("Unrecognized mime type: " + format.name()); - } + return FORMATTERS_PROVIDER.formatters().pitch().format(pitch); } @NonNull @@ -219,9 +205,8 @@ public final class PlayerHelper { ? null : getAutoQueuedSinglePlayQueue(autoQueueItems.get(0)); } - //////////////////////////////////////////////////////////////////////////// - // Settings Resolution - //////////////////////////////////////////////////////////////////////////// + // endregion + // region Resolution public static boolean isResumeAfterAudioFocusGain(@NonNull final Context context) { return getPreferences(context) @@ -405,9 +390,8 @@ public final class PlayerHelper { return Integer.parseInt(preferredIntervalBytes) * 1024; } - //////////////////////////////////////////////////////////////////////////// - // Private helpers - //////////////////////////////////////////////////////////////////////////// + // endregion + // region Private helpers @NonNull private static SharedPreferences getPreferences(@NonNull final Context context) { @@ -427,9 +411,8 @@ public final class PlayerHelper { } - //////////////////////////////////////////////////////////////////////////// - // Utils used by player - //////////////////////////////////////////////////////////////////////////// + // endregion + // region Utils used by player @RepeatMode public static int nextRepeatMode(@RepeatMode final int repeatMode) { @@ -503,4 +486,43 @@ public final class PlayerHelper { player.getContext().getString(R.string.seek_duration_key), player.getContext().getString(R.string.seek_duration_default_value)))); } + + // endregion + // region Format + + static class FormattersProvider { + private Formatters formatters; + + public Formatters formatters() { + if (formatters == null) { + formatters = Formatters.create(); + } + return formatters; + } + + public void reset() { + formatters = null; + } + } + + record Formatters( + Locale locale, + NumberFormat speed, + NumberFormat pitch) { + + static Formatters create() { + final Locale locale = Localization.getAppLocale(); + final DecimalFormatSymbols dfs = DecimalFormatSymbols.getInstance(locale); + return new Formatters( + locale, + new DecimalFormat("0.##x", dfs), + new DecimalFormat("##%", dfs)); + } + + String stringFormat(final String format, final Object... args) { + return String.format(locale, format, args); + } + } + + // endregion } diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java deleted file mode 100644 index ba8a5e0ff..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java +++ /dev/null @@ -1,385 +0,0 @@ -package org.schabi.newpipe.player.helper; - -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.ServiceConnection; -import android.os.IBinder; -import android.util.Log; - -import androidx.annotation.Nullable; -import androidx.core.content.ContextCompat; - -import com.google.android.exoplayer2.PlaybackException; -import com.google.android.exoplayer2.PlaybackParameters; - -import org.schabi.newpipe.App; -import org.schabi.newpipe.MainActivity; -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.player.PlayerService; -import org.schabi.newpipe.player.Player; -import org.schabi.newpipe.player.PlayerType; -import org.schabi.newpipe.player.event.PlayerServiceEventListener; -import org.schabi.newpipe.player.event.PlayerServiceExtendedEventListener; -import org.schabi.newpipe.player.playqueue.PlayQueue; -import org.schabi.newpipe.util.NavigationHelper; - -import java.util.Optional; -import java.util.function.Consumer; - -public final class PlayerHolder { - - private PlayerHolder() { - } - - private static PlayerHolder instance; - public static synchronized PlayerHolder getInstance() { - if (PlayerHolder.instance == null) { - PlayerHolder.instance = new PlayerHolder(); - } - return PlayerHolder.instance; - } - - private static final boolean DEBUG = MainActivity.DEBUG; - private static final String TAG = PlayerHolder.class.getSimpleName(); - - @Nullable private PlayerServiceExtendedEventListener listener; - - private final PlayerServiceConnection serviceConnection = new PlayerServiceConnection(); - private boolean bound; - @Nullable private PlayerService playerService; - - private Optional getPlayer() { - return Optional.ofNullable(playerService) - .flatMap(s -> Optional.ofNullable(s.getPlayer())); - } - - private Optional getPlayQueue() { - // player play queue might be null e.g. while player is starting - return getPlayer().flatMap(p -> Optional.ofNullable(p.getPlayQueue())); - } - - /** - * Returns the current {@link PlayerType} of the {@link PlayerService} service, - * otherwise `null` if no service is running. - * - * @return Current PlayerType - */ - @Nullable - public PlayerType getType() { - return getPlayer().map(Player::getPlayerType).orElse(null); - } - - public boolean isPlaying() { - return getPlayer().map(Player::isPlaying).orElse(false); - } - - public boolean isPlayerOpen() { - return getPlayer().isPresent(); - } - - /** - * Use this method to only allow the user to manipulate the play queue (e.g. by enqueueing via - * the stream long press menu) when there actually is a play queue to manipulate. - * @return true only if the player is open and its play queue is ready (i.e. it is not null) - */ - public boolean isPlayQueueReady() { - return getPlayQueue().isPresent(); - } - - public boolean isBound() { - return bound; - } - - public int getQueueSize() { - return getPlayQueue().map(PlayQueue::size).orElse(0); - } - - public int getQueuePosition() { - return getPlayQueue().map(PlayQueue::getIndex).orElse(0); - } - - public void setListener(@Nullable final PlayerServiceExtendedEventListener newListener) { - listener = newListener; - - if (listener == null) { - return; - } - - // Force reload data from service - if (playerService != null) { - listener.onServiceConnected(playerService); - startPlayerListener(); - // ^ will call listener.onPlayerConnected() down the line if there is an active player - } - } - - // helper to handle context in common place as using the same - // context to bind/unbind a service is crucial - private Context getCommonContext() { - return App.getInstance(); - } - - /** - * Connect to (and if needed start) the {@link PlayerService} - * and bind {@link PlayerServiceConnection} to it. - * If the service is already started, only set the listener. - * @param playAfterConnect If this holder’s service was already started, - * start playing immediately - * @param newListener set this listener - * */ - public void startService(final boolean playAfterConnect, - final PlayerServiceExtendedEventListener newListener) { - if (DEBUG) { - Log.d(TAG, "startService() called with playAfterConnect=" + playAfterConnect); - } - final Context context = getCommonContext(); - setListener(newListener); - if (bound) { - return; - } - // startService() can be called concurrently and it will give a random crashes - // and NullPointerExceptions inside the service because the service will be - // bound twice. Prevent it with unbinding first - unbind(context); - final Intent intent = new Intent(context, PlayerService.class); - intent.putExtra(PlayerService.SHOULD_START_FOREGROUND_EXTRA, true); - ContextCompat.startForegroundService(context, intent); - serviceConnection.doPlayAfterConnect(playAfterConnect); - bind(context); - } - - public void stopService() { - if (DEBUG) { - Log.d(TAG, "stopService() called"); - } - if (playerService != null) { - playerService.destroyPlayerAndStopService(); - } - final Context context = getCommonContext(); - unbind(context); - // destroyPlayerAndStopService() already runs the next line of code, but run it again just - // to make sure to stop the service even if playerService is null by any chance. - context.stopService(new Intent(context, PlayerService.class)); - } - - class PlayerServiceConnection implements ServiceConnection { - - private boolean playAfterConnect = false; - - /** - * @param playAfterConnection Sets the value of `playAfterConnect` to pass to the {@link - * PlayerServiceExtendedEventListener#onPlayerConnected(Player, boolean)} the next time it - * is called. The value of `playAfterConnect` will be reset to false after that. - */ - public void doPlayAfterConnect(final boolean playAfterConnection) { - this.playAfterConnect = playAfterConnection; - } - - @Override - public void onServiceDisconnected(final ComponentName compName) { - if (DEBUG) { - Log.d(TAG, "Player service is disconnected"); - } - - final Context context = getCommonContext(); - unbind(context); - } - - @Override - public void onServiceConnected(final ComponentName compName, final IBinder service) { - if (DEBUG) { - Log.d(TAG, "Player service is connected"); - } - final PlayerService.LocalBinder localBinder = (PlayerService.LocalBinder) service; - - @Nullable final PlayerService s = localBinder.getService(); - if (s == null) { - throw new IllegalArgumentException( - "PlayerService.LocalBinder.getService() must never be" - + "null after the service connects"); - } - playerService = s; - if (listener != null) { - listener.onServiceConnected(s); - getPlayer().ifPresent(p -> listener.onPlayerConnected(p, playAfterConnect)); - } - startPlayerListener(); - // ^ will call listener.onPlayerConnected() down the line if there is an active player - - // notify the main activity that binding the service has completed, so that it can - // open the bottom mini-player - NavigationHelper.sendPlayerStartedEvent(s); - } - } - - private void bind(final Context context) { - if (DEBUG) { - Log.d(TAG, "bind() called"); - } - // BIND_AUTO_CREATE starts the service if it's not already running - bound = bind(context, Context.BIND_AUTO_CREATE); - if (!bound) { - context.unbindService(serviceConnection); - } - } - - public void tryBindIfNeeded(final Context context) { - if (!bound) { - // flags=0 means the service will not be started if it does not already exist. In this - // case the return value is not useful, as a value of "true" does not really indicate - // that the service is going to be bound. - bind(context, 0); - } - } - - private boolean bind(final Context context, final int flags) { - final Intent serviceIntent = new Intent(context, PlayerService.class); - serviceIntent.setAction(PlayerService.BIND_PLAYER_HOLDER_ACTION); - return context.bindService(serviceIntent, serviceConnection, flags); - } - - private void unbind(final Context context) { - if (DEBUG) { - Log.d(TAG, "unbind() called"); - } - - if (bound) { - context.unbindService(serviceConnection); - bound = false; - stopPlayerListener(); - playerService = null; - if (listener != null) { - listener.onPlayerDisconnected(); - listener.onServiceDisconnected(); - } - } - } - - private void startPlayerListener() { - if (playerService != null) { - // setting the player listener will take care of calling relevant callbacks if the - // player in the service is (not) already active, also see playerStateListener below - playerService.setPlayerListener(playerStateListener); - } - getPlayer().ifPresent(p -> p.setFragmentListener(internalListener)); - } - - private void stopPlayerListener() { - if (playerService != null) { - playerService.setPlayerListener(null); - } - getPlayer().ifPresent(p -> p.removeFragmentListener(internalListener)); - } - - /** - * This listener will be held by the players created by {@link PlayerService}. - */ - private final PlayerServiceEventListener internalListener = - new PlayerServiceEventListener() { - @Override - public void onViewCreated() { - if (listener != null) { - listener.onViewCreated(); - } - } - - @Override - public void onFullscreenStateChanged(final boolean fullscreen) { - if (listener != null) { - listener.onFullscreenStateChanged(fullscreen); - } - } - - @Override - public void onScreenRotationButtonClicked() { - if (listener != null) { - listener.onScreenRotationButtonClicked(); - } - } - - @Override - public void onMoreOptionsLongClicked() { - if (listener != null) { - listener.onMoreOptionsLongClicked(); - } - } - - @Override - public void onPlayerError(final PlaybackException error, - final boolean isCatchableException) { - if (listener != null) { - listener.onPlayerError(error, isCatchableException); - } - } - - @Override - public void hideSystemUiIfNeeded() { - if (listener != null) { - listener.hideSystemUiIfNeeded(); - } - } - - @Override - public void onQueueUpdate(final PlayQueue queue) { - if (listener != null) { - listener.onQueueUpdate(queue); - } - } - - @Override - public void onPlaybackUpdate(final int state, - final int repeatMode, - final boolean shuffled, - final PlaybackParameters parameters) { - if (listener != null) { - listener.onPlaybackUpdate(state, repeatMode, shuffled, parameters); - } - } - - @Override - public void onProgressUpdate(final int currentProgress, - final int duration, - final int bufferPercent) { - if (listener != null) { - listener.onProgressUpdate(currentProgress, duration, bufferPercent); - } - } - - @Override - public void onMetadataUpdate(final StreamInfo info, final PlayQueue queue) { - if (listener != null) { - listener.onMetadataUpdate(info, queue); - } - } - - @Override - public void onServiceStopped() { - if (listener != null) { - listener.onServiceStopped(); - } - unbind(getCommonContext()); - } - }; - - /** - * This listener will be held by bound {@link PlayerService}s to notify of the player starting - * or stopping. This is necessary since the service outlives the player e.g. to answer Android - * Auto media browser queries. - */ - private final Consumer playerStateListener = (@Nullable final Player player) -> { - if (listener != null) { - if (player == null) { - // player.fragmentListener=null is already done by player.stopActivityBinding(), - // which is called by player.destroy(), which is in turn called by PlayerService - // before setting its player to null - listener.onPlayerDisconnected(); - } else { - listener.onPlayerConnected(player, serviceConnection.playAfterConnect); - // reset the value of playAfterConnect: if it was true before, it is now "consumed" - serviceConnection.playAfterConnect = false; - player.setFragmentListener(internalListener); - } - } - }; -} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt new file mode 100644 index 000000000..1b0cedfc5 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt @@ -0,0 +1,316 @@ +package org.schabi.newpipe.player.helper + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.IBinder +import android.util.Log +import androidx.core.content.ContextCompat +import com.google.android.exoplayer2.PlaybackException +import com.google.android.exoplayer2.PlaybackParameters +import org.schabi.newpipe.App +import org.schabi.newpipe.MainActivity +import org.schabi.newpipe.extractor.stream.StreamInfo +import org.schabi.newpipe.player.Player +import org.schabi.newpipe.player.PlayerService +import org.schabi.newpipe.player.PlayerService.LocalBinder +import org.schabi.newpipe.player.PlayerType +import org.schabi.newpipe.player.event.PlayerServiceEventListener +import org.schabi.newpipe.player.event.PlayerServiceExtendedEventListener +import org.schabi.newpipe.player.playqueue.PlayQueue +import org.schabi.newpipe.util.NavigationHelper + +private val DEBUG = MainActivity.DEBUG +private val TAG: String = PlayerHolder::class.java.getSimpleName() + +/** + * Singleton that manages a `PlayerService` + * and can be used to control the player instance through the service. + */ +object PlayerHolder { + private var listener: PlayerServiceExtendedEventListener? = null + + var isBound: Boolean = false + private set + + private var playerService: PlayerService? = null + + private val player: Player? + get() = playerService?.player + + // player play queue might be null e.g. while player is starting + private val playQueue: PlayQueue? + get() = this.player?.playQueue + + val type: PlayerType? + /** + * Returns the current [PlayerType] of the [PlayerService] service, + * otherwise `null` if no service is running. + * + * @return Current PlayerType + */ + get() = this.player?.playerType + + val isPlaying: Boolean + get() = this.player?.isPlaying == true + + val isPlayerOpen: Boolean + get() = this.player != null + + val isPlayQueueReady: Boolean + /** + * Use this method to only allow the user to manipulate the play queue (e.g. by enqueueing via + * the stream long press menu) when there actually is a play queue to manipulate. + * @return true only if the player is open and its play queue is ready (i.e. it is not null) + */ + get() = this.playQueue != null + + val queueSize: Int + get() = this.playQueue?.size() ?: 0 + + val queuePosition: Int + get() = this.playQueue?.index ?: 0 + + fun setListener(newListener: PlayerServiceExtendedEventListener?) { + listener = newListener + + // Force reload data from service + newListener?.let { listener -> + playerService?.let { service -> + listener.onServiceConnected(service) + startPlayerListener() + // ^ will call listener.onPlayerConnected() down the line if there is an active player + } + } + } + + private val commonContext: Context + // helper to handle context in common place as using the same + get() = App.instance + + /** + * Connect to (and if needed start) the [PlayerService] + * and bind [PlayerServiceConnection] to it. + * If the service is already started, only set the listener. + * @param playAfterConnect If this holder’s service was already started, + * start playing immediately + * @param newListener set this listener + */ + fun startService( + playAfterConnect: Boolean, + newListener: PlayerServiceExtendedEventListener? + ) { + if (DEBUG) { + Log.d(TAG, "startService() called with playAfterConnect=$playAfterConnect") + } + val context = this.commonContext + setListener(newListener) + if (this.isBound) { + return + } + // startService() can be called concurrently and it will give a random crashes + // and NullPointerExceptions inside the service because the service will be + // bound twice. Prevent it with unbinding first + unbind(context) + val intent = Intent(context, PlayerService::class.java) + intent.putExtra(PlayerService.SHOULD_START_FOREGROUND_EXTRA, true) + ContextCompat.startForegroundService(context, intent) + PlayerServiceConnection.doPlayAfterConnect(playAfterConnect) + bind(context) + } + + fun stopService() { + if (DEBUG) { + Log.d(TAG, "stopService() called") + } + playerService?.destroyPlayerAndStopService() + val context = this.commonContext + unbind(context) + // destroyPlayerAndStopService() already runs the next line of code, but run it again just + // to make sure to stop the service even if playerService is null by any chance. + context.stopService(Intent(context, PlayerService::class.java)) + } + + internal object PlayerServiceConnection : ServiceConnection { + internal var playAfterConnect = false + + /** + * @param playAfterConnection Sets the value of [playAfterConnect] to pass to the + * [PlayerServiceExtendedEventListener.onPlayerConnected] the next time it + * is called. The value of [playAfterConnect] will be reset to false after that. + */ + fun doPlayAfterConnect(playAfterConnection: Boolean) { + this.playAfterConnect = playAfterConnection + } + + override fun onServiceDisconnected(compName: ComponentName?) { + if (DEBUG) { + Log.d(TAG, "Player service is disconnected") + } + + val context: Context = this@PlayerHolder.commonContext + unbind(context) + } + + override fun onServiceConnected(compName: ComponentName?, service: IBinder?) { + if (DEBUG) { + Log.d(TAG, "Player service is connected") + } + val localBinder = service as LocalBinder + + val s = localBinder.service + requireNotNull(s) { + "PlayerService.LocalBinder.getService() must never be" + + "null after the service connects" + } + playerService = s + listener?.let { l -> + l.onServiceConnected(s) + player?.let { l.onPlayerConnected(it, playAfterConnect) } + } + startPlayerListener() + // ^ will call listener.onPlayerConnected() down the line if there is an active player + + // notify the main activity that binding the service has completed, so that it can + // open the bottom mini-player + NavigationHelper.sendPlayerStartedEvent(s) + } + } + + private fun bind(context: Context) { + if (DEBUG) { + Log.d(TAG, "bind() called") + } + // BIND_AUTO_CREATE starts the service if it's not already running + this.isBound = bind(context, Context.BIND_AUTO_CREATE) + if (!this.isBound) { + context.unbindService(PlayerServiceConnection) + } + } + + fun tryBindIfNeeded(context: Context) { + if (!this.isBound) { + // flags=0 means the service will not be started if it does not already exist. In this + // case the return value is not useful, as a value of "true" does not really indicate + // that the service is going to be bound. + bind(context, 0) + } + } + + private fun bind(context: Context, flags: Int): Boolean { + val serviceIntent = Intent(context, PlayerService::class.java) + serviceIntent.setAction(PlayerService.BIND_PLAYER_HOLDER_ACTION) + return context.bindService(serviceIntent, PlayerServiceConnection, flags) + } + + private fun unbind(context: Context) { + if (DEBUG) { + Log.d(TAG, "unbind() called") + } + + if (this.isBound) { + context.unbindService(PlayerServiceConnection) + this.isBound = false + stopPlayerListener() + playerService = null + listener?.onPlayerDisconnected() + listener?.onServiceDisconnected() + } + } + + private fun startPlayerListener() { + // setting the player listener will take care of calling relevant callbacks if the + // player in the service is (not) already active, also see playerStateListener below + playerService?.setPlayerListener(playerStateListener) + this.player?.setFragmentListener(HolderPlayerServiceEventListener) + } + + private fun stopPlayerListener() { + playerService?.setPlayerListener(null) + this.player?.removeFragmentListener(HolderPlayerServiceEventListener) + } + + /** + * This listener will be held by the players created by [PlayerService]. + */ + private object HolderPlayerServiceEventListener : PlayerServiceEventListener { + override fun onViewCreated() { + listener?.onViewCreated() + } + + override fun onFullscreenStateChanged(fullscreen: Boolean) { + listener?.onFullscreenStateChanged(fullscreen) + } + + override fun onScreenRotationButtonClicked() { + listener?.onScreenRotationButtonClicked() + } + + override fun onMoreOptionsLongClicked() { + listener?.onMoreOptionsLongClicked() + } + + override fun onPlayerError( + error: PlaybackException?, + isCatchableException: Boolean + ) { + listener?.onPlayerError(error, isCatchableException) + } + + override fun hideSystemUiIfNeeded() { + listener?.hideSystemUiIfNeeded() + } + + override fun onQueueUpdate(queue: PlayQueue?) { + listener?.onQueueUpdate(queue) + } + + override fun onPlaybackUpdate( + state: Int, + repeatMode: Int, + shuffled: Boolean, + parameters: PlaybackParameters? + ) { + listener?.onPlaybackUpdate(state, repeatMode, shuffled, parameters) + } + + override fun onProgressUpdate( + currentProgress: Int, + duration: Int, + bufferPercent: Int + ) { + listener?.onProgressUpdate(currentProgress, duration, bufferPercent) + } + + override fun onMetadataUpdate(info: StreamInfo?, queue: PlayQueue?) { + listener?.onMetadataUpdate(info, queue) + } + + override fun onServiceStopped() { + listener?.onServiceStopped() + unbind(this@PlayerHolder.commonContext) + } + } + + /** + * This listener will be held by bound [PlayerService]s to notify of the player starting + * or stopping. This is necessary since the service outlives the player e.g. to answer Android + * Auto media browser queries. + */ + private val playerStateListener: (Player?) -> Unit = { player: Player? -> + listener?.let { l -> + if (player == null) { + // player.fragmentListener=null is already done by player.stopActivityBinding(), + // which is called by player.destroy(), which is in turn called by PlayerService + // before setting its player to null + l.onPlayerDisconnected() + } else { + l.onPlayerConnected(player, PlayerServiceConnection.playAfterConnect) + // reset the value of playAfterConnect: if it was true before, it is now "consumed" + PlayerServiceConnection.playAfterConnect = false + player.setFragmentListener(HolderPlayerServiceEventListener) + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserImpl.kt b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserImpl.kt index f15d7ab08..149f47c1a 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserImpl.kt +++ b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserImpl.kt @@ -9,7 +9,9 @@ import android.support.v4.media.MediaDescriptionCompat import android.util.Log import androidx.annotation.DrawableRes import androidx.core.net.toUri +import androidx.core.os.bundleOf import androidx.media.MediaBrowserServiceCompat +import androidx.media.MediaBrowserServiceCompat.BrowserRoot.EXTRA_RECENT import androidx.media.MediaBrowserServiceCompat.Result import androidx.media.utils.MediaConstants import io.reactivex.rxjava3.core.Flowable @@ -36,7 +38,6 @@ import org.schabi.newpipe.local.playlist.RemotePlaylistManager import org.schabi.newpipe.util.ExtractorHelper import org.schabi.newpipe.util.ServiceHelper import org.schabi.newpipe.util.image.ImageStrategy -import java.util.function.Consumer /** * This class is used to cleanly separate the Service implementation (in @@ -46,16 +47,15 @@ import java.util.function.Consumer */ class MediaBrowserImpl( private val context: Context, - notifyChildrenChanged: Consumer, // parentId + notifyChildrenChanged: (parentId: String) -> Unit, ) { + private val packageValidator = PackageValidator(context) private val database = NewPipeDatabase.getInstance(context) private var disposables = CompositeDisposable() init { // this will listen to changes in the bookmarks until this MediaBrowserImpl is dispose()d - disposables.add( - getMergedPlaylists().subscribe { notifyChildrenChanged.accept(ID_BOOKMARKS) } - ) + disposables.add(getMergedPlaylists().subscribe { notifyChildrenChanged(ID_BOOKMARKS) }) } //region Cleanup @@ -69,11 +69,22 @@ class MediaBrowserImpl( clientPackageName: String, clientUid: Int, rootHints: Bundle? - ): MediaBrowserServiceCompat.BrowserRoot { + ): MediaBrowserServiceCompat.BrowserRoot? { if (DEBUG) { Log.d(TAG, "onGetRoot($clientPackageName, $clientUid, $rootHints)") } + if (!packageValidator.isKnownCaller(clientPackageName, clientUid)) { + // this is a caller we can't trust (see PackageValidator's rules taken from uamp) + return null + } + + if (rootHints?.getBoolean(EXTRA_RECENT, false) == true) { + // the system is asking for a root to do media resumption, but we can't handle that yet, + // see https://developer.android.com/media/implement/surfaces/mobile#mediabrowserservice_implementation + return null + } + val extras = Bundle() extras.putBoolean( MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true @@ -183,17 +194,16 @@ class MediaBrowserImpl( private fun createPlaylistMediaItem(playlist: PlaylistLocalItem): MediaBrowserCompat.MediaItem { val builder = MediaDescriptionCompat.Builder() - builder .setMediaId(createMediaIdForInfoItem(playlist is PlaylistRemoteEntity, playlist.uid)) .setTitle(playlist.orderingName) .setIconUri(imageUriOrNullIfDisabled(playlist.thumbnailUrl)) + .setExtras( + bundleOf( + MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE + to context.resources.getString(R.string.tab_bookmarks) + ) + ) - val extras = Bundle() - extras.putString( - MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, - context.resources.getString(R.string.tab_bookmarks), - ) - builder.setExtras(extras) return MediaBrowserCompat.MediaItem( builder.build(), MediaBrowserCompat.MediaItem.FLAG_BROWSABLE, @@ -202,8 +212,9 @@ class MediaBrowserImpl( private fun createInfoItemMediaItem(item: InfoItem): MediaBrowserCompat.MediaItem? { val builder = MediaDescriptionCompat.Builder() - builder.setMediaId(createMediaIdForInfoItem(item)) + .setMediaId(createMediaIdForInfoItem(item)) .setTitle(item.name) + .setIconUri(ImageStrategy.choosePreferredImage(item.thumbnails)?.toUri()) when (item.infoType) { InfoType.STREAM -> builder.setSubtitle((item as StreamInfoItem).uploaderName) @@ -212,10 +223,6 @@ class MediaBrowserImpl( else -> return null } - ImageStrategy.choosePreferredImage(item.thumbnails)?.let { - builder.setIconUri(imageUriOrNullIfDisabled(it)) - } - return MediaBrowserCompat.MediaItem( builder.build(), MediaBrowserCompat.MediaItem.FLAG_PLAYABLE @@ -256,7 +263,7 @@ class MediaBrowserImpl( index: Int, ): MediaBrowserCompat.MediaItem { val builder = MediaDescriptionCompat.Builder() - builder.setMediaId(createMediaIdForPlaylistIndex(false, playlistId, index)) + .setMediaId(createMediaIdForPlaylistIndex(false, playlistId, index)) .setTitle(item.streamEntity.title) .setSubtitle(item.streamEntity.uploader) .setIconUri(imageUriOrNullIfDisabled(item.streamEntity.thumbnailUrl)) @@ -276,10 +283,7 @@ class MediaBrowserImpl( builder.setMediaId(createMediaIdForPlaylistIndex(true, playlistId, index)) .setTitle(item.name) .setSubtitle(item.uploaderName) - - ImageStrategy.choosePreferredImage(item.thumbnails)?.let { - builder.setIconUri(imageUriOrNullIfDisabled(it)) - } + .setIconUri(ImageStrategy.choosePreferredImage(item.thumbnails)?.toUri()) return MediaBrowserCompat.MediaItem( builder.build(), diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserPlaybackPreparer.kt b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserPlaybackPreparer.kt index a3791e2e7..2948eeaf8 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserPlaybackPreparer.kt +++ b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserPlaybackPreparer.kt @@ -6,6 +6,7 @@ import android.os.Bundle import android.os.ResultReceiver import android.support.v4.media.session.PlaybackStateCompat import android.util.Log +import androidx.core.content.ContextCompat import androidx.core.net.toUri import com.google.android.exoplayer2.Player import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector.PlaybackPreparer @@ -109,14 +110,14 @@ class MediaBrowserPlaybackPreparer( //region Errors private fun onUnsupportedError() { setMediaSessionError.accept( - context.getString(R.string.content_not_supported), + ContextCompat.getString(context, R.string.content_not_supported), PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED ) } private fun onPrepareError() { setMediaSessionError.accept( - context.getString(R.string.error_snackbar_message), + ContextCompat.getString(context, R.string.error_snackbar_message), PlaybackStateCompat.ERROR_CODE_APP_ERROR ) } diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/PackageValidator.kt b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/PackageValidator.kt new file mode 100644 index 000000000..05719b6d4 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/PackageValidator.kt @@ -0,0 +1,240 @@ +/* + * Copyright 2018 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// THIS FILE WAS TAKEN FROM UAMP, EXCEPT FOR THINGS RELATED TO THE WHITELIST. UPDATE IT WHEN NEEDED. +// https://github.com/android/uamp/blob/329a21b63c247e9bd35f6858d4fc0e448fa38603/common/src/main/java/com/example/android/uamp/media/PackageValidator.kt + +package org.schabi.newpipe.player.mediabrowser + +import android.Manifest.permission.MEDIA_CONTENT_CONTROL +import android.annotation.SuppressLint +import android.content.Context +import android.content.pm.PackageInfo +import android.content.pm.PackageInfo.REQUESTED_PERMISSION_GRANTED +import android.content.pm.PackageManager +import android.os.Process +import android.support.v4.media.session.MediaSessionCompat +import android.util.Log +import androidx.core.app.NotificationManagerCompat +import androidx.media.MediaBrowserServiceCompat +import org.schabi.newpipe.BuildConfig +import java.security.MessageDigest +import java.security.NoSuchAlgorithmException + +/** + * Validates that the calling package is authorized to browse a [MediaBrowserServiceCompat]. + * + * The list of allowed signing certificates and their corresponding package names is defined in + * res/xml/allowed_media_browser_callers.xml. + * + * If you want to add a new caller to allowed_media_browser_callers.xml and you don't know + * its signature, this class will print to logcat (INFO level) a message with the proper + * xml tags to add to allow the caller. + * + * For more information, see res/xml/allowed_media_browser_callers.xml. + */ +internal class PackageValidator(context: Context) { + private val context: Context = context.applicationContext + private val packageManager: PackageManager = this.context.packageManager + private val platformSignature: String = getSystemSignature() + private val callerChecked = mutableMapOf>() + + /** + * Checks whether the caller attempting to connect to a [MediaBrowserServiceCompat] is known. + * See [MusicService.onGetRoot] for where this is utilized. + * + * @param callingPackage The package name of the caller. + * @param callingUid The user id of the caller. + * @return `true` if the caller is known, `false` otherwise. + */ + fun isKnownCaller(callingPackage: String, callingUid: Int): Boolean { + // If the caller has already been checked, return the previous result here. + val (checkedUid, checkResult) = callerChecked[callingPackage] ?: Pair(0, false) + if (checkedUid == callingUid) { + return checkResult + } + + /** + * Because some of these checks can be slow, we save the results in [callerChecked] after + * this code is run. + * + * In particular, there's little reason to recompute the calling package's certificate + * signature (SHA-256) each call. + * + * This is safe to do as we know the UID matches the package's UID (from the check above), + * and app UIDs are set at install time. Additionally, a package name + UID is guaranteed to + * be constant until a reboot. (After a reboot then a previously assigned UID could be + * reassigned.) + */ + + // Build the caller info for the rest of the checks here. + val callerPackageInfo = buildCallerInfo(callingPackage) + ?: throw IllegalStateException("Caller wasn't found in the system?") + + // Verify that things aren't ... broken. (This test should always pass.) + if (callerPackageInfo.uid != callingUid) { + throw IllegalStateException("Caller's package UID doesn't match caller's actual UID?") + } + + val callerSignature = callerPackageInfo.signature + + val isCallerKnown = when { + // If it's our own app making the call, allow it. + callingUid == Process.myUid() -> true + // If the system is making the call, allow it. + callingUid == Process.SYSTEM_UID -> true + // If the app was signed by the same certificate as the platform itself, also allow it. + callerSignature == platformSignature -> true + /** + * [MEDIA_CONTENT_CONTROL] permission is only available to system applications, and + * while it isn't required to allow these apps to connect to a + * [MediaBrowserServiceCompat], allowing this ensures optimal compatability with apps + * such as Android TV and the Google Assistant. + */ + callerPackageInfo.permissions.contains(MEDIA_CONTENT_CONTROL) -> true + /** + * If the calling app has a notification listener it is able to retrieve notifications + * and can connect to an active [MediaSessionCompat]. + * + * It's not required to allow apps with a notification listener to + * connect to your [MediaBrowserServiceCompat], but it does allow easy compatibility + * with apps such as Wear OS. + */ + NotificationManagerCompat.getEnabledListenerPackages(this.context) + .contains(callerPackageInfo.packageName) -> true + + // If none of the previous checks succeeded, then the caller is unrecognized. + else -> false + } + + if (!isCallerKnown) { + logUnknownCaller(callerPackageInfo) + } + + // Save our work for next time. + callerChecked[callingPackage] = Pair(callingUid, isCallerKnown) + return isCallerKnown + } + + /** + * Logs an info level message with details of how to add a caller to the allowed callers list + * when the app is debuggable. + */ + private fun logUnknownCaller(callerPackageInfo: CallerPackageInfo) { + if (BuildConfig.DEBUG) { + Log.w(TAG, "Unknown caller $callerPackageInfo") + } + } + + /** + * Builds a [CallerPackageInfo] for a given package that can be used for all the + * various checks that are performed before allowing an app to connect to a + * [MediaBrowserServiceCompat]. + */ + private fun buildCallerInfo(callingPackage: String): CallerPackageInfo? { + val packageInfo = getPackageInfo(callingPackage) ?: return null + + val appName = packageInfo.applicationInfo?.loadLabel(packageManager).toString() + val uid = packageInfo.applicationInfo?.uid ?: -1 + val signature = getSignature(packageInfo) + + val requestedPermissions = packageInfo.requestedPermissions?.asSequence().orEmpty() + val permissionFlags = packageInfo.requestedPermissionsFlags?.asSequence().orEmpty() + val activePermissions = (requestedPermissions zip permissionFlags) + .filter { (permission, flag) -> flag and REQUESTED_PERMISSION_GRANTED != 0 } + .mapTo(mutableSetOf()) { (permission, flag) -> permission } + + return CallerPackageInfo(appName, callingPackage, uid, signature, activePermissions.toSet()) + } + + /** + * Looks up the [PackageInfo] for a package name. + * This requests both the signatures (for checking if an app is on the allow list) and + * the app's permissions, which allow for more flexibility in the allow list. + * + * @return [PackageInfo] for the package name or null if it's not found. + */ + @Suppress("deprecation") + @SuppressLint("PackageManagerGetSignatures") + private fun getPackageInfo(callingPackage: String): PackageInfo? = + packageManager.getPackageInfo( + callingPackage, + PackageManager.GET_SIGNATURES or PackageManager.GET_PERMISSIONS + ) + + /** + * Gets the signature of a given package's [PackageInfo]. + * + * The "signature" is a SHA-256 hash of the public key of the signing certificate used by + * the app. + * + * If the app is not found, or if the app does not have exactly one signature, this method + * returns `null` as the signature. + */ + @Suppress("deprecation") + private fun getSignature(packageInfo: PackageInfo): String? = + if (packageInfo.signatures == null || packageInfo.signatures!!.size != 1) { + // Security best practices dictate that an app should be signed with exactly one (1) + // signature. Because of this, if there are multiple signatures, reject it. + null + } else { + val certificate = packageInfo.signatures!![0].toByteArray() + getSignatureSha256(certificate) + } + + /** + * Finds the Android platform signing key signature. This key is never null. + */ + private fun getSystemSignature(): String = + getPackageInfo(ANDROID_PLATFORM)?.let { platformInfo -> + getSignature(platformInfo) + } ?: throw IllegalStateException("Platform signature not found") + + /** + * Creates a SHA-256 signature given a certificate byte array. + */ + private fun getSignatureSha256(certificate: ByteArray): String { + val md: MessageDigest + try { + md = MessageDigest.getInstance("SHA256") + } catch (noSuchAlgorithmException: NoSuchAlgorithmException) { + Log.e(TAG, "No such algorithm: $noSuchAlgorithmException") + throw RuntimeException("Could not find SHA256 hash algorithm", noSuchAlgorithmException) + } + md.update(certificate) + + // This code takes the byte array generated by `md.digest()` and joins each of the bytes + // to a string, applying the string format `%02x` on each digit before it's appended, with + // a colon (':') between each of the items. + // For example: input=[0,2,4,6,8,10,12], output="00:02:04:06:08:0a:0c" + return md.digest().joinToString(":") { String.format("%02x", it) } + } + + /** + * Convenience class to hold all of the information about an app that's being checked + * to see if it's a known caller. + */ + private data class CallerPackageInfo( + val name: String, + val packageName: String, + val uid: Int, + val signature: String?, + val permissions: Set + ) +} + +private const val TAG = "PackageValidator" +private const val ANDROID_PLATFORM = "android" diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java b/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java index 085da5eb7..850dd02e3 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java +++ b/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java @@ -124,8 +124,10 @@ public class MediaSessionPlayerUi extends PlayerUi MediaButtonReceiver.handleIntent(mediaSession, intent); } - public Optional getSessionToken() { - return Optional.ofNullable(mediaSession).map(MediaSessionCompat::getSessionToken); + + @NonNull + public MediaSessionCompat.Token getSessionToken() { + return mediaSession.getSessionToken(); } @@ -138,7 +140,10 @@ public class MediaSessionPlayerUi extends PlayerUi public void play() { player.play(); // hide the player controls even if the play command came from the media session - player.UIs().getOpt(VideoPlayerUi.class).ifPresent(ui -> ui.hideControls(0, 0)); + final VideoPlayerUi ui = player.UIs().get(VideoPlayerUi.class); + if (ui != null) { + ui.hideControls(0, 0); + } } @Override diff --git a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java index 5658693f2..cc3889973 100644 --- a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java +++ b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java @@ -101,10 +101,10 @@ public final class NotificationUtil { final int[] compactSlots = initializeNotificationSlots(); mediaStyle.setShowActionsInCompactView(compactSlots); } - player.UIs() - .getOpt(MediaSessionPlayerUi.class) - .flatMap(MediaSessionPlayerUi::getSessionToken) - .ifPresent(mediaStyle::setMediaSession); + @Nullable final MediaSessionPlayerUi ui = player.UIs().get(MediaSessionPlayerUi.class); + if (ui != null) { + mediaStyle.setMediaSession(ui.getSessionToken()); + } // setup notification builder builder.setStyle(mediaStyle) diff --git a/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java b/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java index 88d7145bc..9092906fa 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java +++ b/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java @@ -38,9 +38,9 @@ import io.reactivex.rxjava3.internal.subscriptions.EmptySubscription; import io.reactivex.rxjava3.schedulers.Schedulers; import io.reactivex.rxjava3.subjects.PublishSubject; +import static org.schabi.newpipe.BuildConfig.DEBUG; import static org.schabi.newpipe.player.mediasource.FailedMediaSource.MediaSourceResolutionException; import static org.schabi.newpipe.player.mediasource.FailedMediaSource.StreamInfoLoadException; -import static org.schabi.newpipe.player.playqueue.PlayQueue.DEBUG; import static org.schabi.newpipe.util.ServiceHelper.getCacheExpirationMillis; public class MediaSourceManager { diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/AbstractInfoPlayQueue.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/AbstractInfoPlayQueue.java index dbfac5cca..02bb6b5ba 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/AbstractInfoPlayQueue.java +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/AbstractInfoPlayQueue.java @@ -3,6 +3,7 @@ package org.schabi.newpipe.player.playqueue; import android.util.Log; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.ListExtractor; @@ -23,6 +24,7 @@ abstract class AbstractInfoPlayQueue> final int serviceId; final String baseUrl; + @Nullable Page nextPage; private transient Disposable fetchReactor; diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java deleted file mode 100644 index cfa2ab316..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java +++ /dev/null @@ -1,561 +0,0 @@ -package org.schabi.newpipe.player.playqueue; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.schabi.newpipe.MainActivity; -import org.schabi.newpipe.player.playqueue.events.AppendEvent; -import org.schabi.newpipe.player.playqueue.events.ErrorEvent; -import org.schabi.newpipe.player.playqueue.events.InitEvent; -import org.schabi.newpipe.player.playqueue.events.MoveEvent; -import org.schabi.newpipe.player.playqueue.events.PlayQueueEvent; -import org.schabi.newpipe.player.playqueue.events.RecoveryEvent; -import org.schabi.newpipe.player.playqueue.events.RemoveEvent; -import org.schabi.newpipe.player.playqueue.events.ReorderEvent; -import org.schabi.newpipe.player.playqueue.events.SelectEvent; - -import java.io.Serializable; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.BackpressureStrategy; -import io.reactivex.rxjava3.core.Flowable; -import io.reactivex.rxjava3.subjects.BehaviorSubject; - -/** - * PlayQueue is responsible for keeping track of a list of streams and the index of - * the stream that should be currently playing. - *

- * This class contains basic manipulation of a playlist while also functions as a - * message bus, providing all listeners with new updates to the play queue. - *

- *

- * This class can be serialized for passing intents, but in order to start the - * message bus, it must be initialized. - *

- */ -public abstract class PlayQueue implements Serializable { - public static final boolean DEBUG = MainActivity.DEBUG; - @NonNull - private final AtomicInteger queueIndex; - private final List history = new ArrayList<>(); - - private List backup; - private List streams; - - private transient BehaviorSubject eventBroadcast; - private transient Flowable broadcastReceiver; - private transient boolean disposed = false; - - PlayQueue(final int index, final List startWith) { - streams = new ArrayList<>(startWith); - - if (streams.size() > index) { - history.add(streams.get(index)); - } - - queueIndex = new AtomicInteger(index); - } - - /*////////////////////////////////////////////////////////////////////////// - // Playlist actions - //////////////////////////////////////////////////////////////////////////*/ - - /** - * Initializes the play queue message buses. - *

- * Also starts a self reporter for logging if debug mode is enabled. - *

- */ - public void init() { - eventBroadcast = BehaviorSubject.create(); - - broadcastReceiver = eventBroadcast.toFlowable(BackpressureStrategy.BUFFER) - .observeOn(AndroidSchedulers.mainThread()) - .startWithItem(new InitEvent()); - } - - /** - * Dispose the play queue by stopping all message buses. - */ - public void dispose() { - if (eventBroadcast != null) { - eventBroadcast.onComplete(); - } - - eventBroadcast = null; - broadcastReceiver = null; - disposed = true; - } - - /** - * Checks if the queue is complete. - *

- * A queue is complete if it has loaded all items in an external playlist - * single stream or local queues are always complete. - *

- * - * @return whether the queue is complete - */ - public abstract boolean isComplete(); - - /** - * Load partial queue in the background, does nothing if the queue is complete. - */ - public abstract void fetch(); - - /*////////////////////////////////////////////////////////////////////////// - // Readonly ops - //////////////////////////////////////////////////////////////////////////*/ - - /** - * @return the current index that should be played - */ - public int getIndex() { - return queueIndex.get(); - } - - /** - * Changes the current playing index to a new index. - *

- * This method is guarded using in a circular manner for index exceeding the play queue size. - *

- *

- * Will emit a {@link SelectEvent} if the index is not the current playing index. - *

- * - * @param index the index to be set - */ - public synchronized void setIndex(final int index) { - final int oldIndex = getIndex(); - - final int newIndex; - - if (index < 0) { - newIndex = 0; - } else if (index < streams.size()) { - // Regular assignment for index in bounds - newIndex = index; - } else if (streams.isEmpty()) { - // Out of bounds from here on - // Need to check if stream is empty to prevent arithmetic error and negative index - newIndex = 0; - } else if (isComplete()) { - // Circular indexing - newIndex = index % streams.size(); - } else { - // Index of last element - newIndex = streams.size() - 1; - } - - queueIndex.set(newIndex); - - if (oldIndex != newIndex) { - history.add(streams.get(newIndex)); - } - - /* - TODO: Documentation states that a SelectEvent will only be emitted if the new index is... - different from the old one but this is emitted regardless? Not sure what this what it does - exactly so I won't touch it - */ - broadcast(new SelectEvent(oldIndex, newIndex)); - } - - /** - * @return the current item that should be played, or null if the queue is empty - */ - @Nullable - public PlayQueueItem getItem() { - return getItem(getIndex()); - } - - /** - * @param index the index of the item to return - * @return the item at the given index, or null if the index is out of bounds - */ - @Nullable - public PlayQueueItem getItem(final int index) { - if (index < 0 || index >= streams.size()) { - return null; - } - return streams.get(index); - } - - /** - * Returns the index of the given item using referential equality. - * May be null despite play queue contains identical item. - * - * @param item the item to find the index of - * @return the index of the given item - */ - public int indexOf(@NonNull final PlayQueueItem item) { - return streams.indexOf(item); - } - - /** - * @return the current size of play queue. - */ - public int size() { - return streams.size(); - } - - /** - * Checks if the play queue is empty. - * - * @return whether the play queue is empty - */ - public boolean isEmpty() { - return streams.isEmpty(); - } - - /** - * Determines if the current play queue is shuffled. - * - * @return whether the play queue is shuffled - */ - public boolean isShuffled() { - return backup != null; - } - - /** - * @return an immutable view of the play queue - */ - @NonNull - public List getStreams() { - return Collections.unmodifiableList(streams); - } - - /*////////////////////////////////////////////////////////////////////////// - // Write ops - //////////////////////////////////////////////////////////////////////////*/ - - /** - * Returns the play queue's update broadcast. - * May be null if the play queue message bus is not initialized. - * - * @return the play queue's update broadcast - */ - @Nullable - public Flowable getBroadcastReceiver() { - return broadcastReceiver; - } - - /** - * Changes the current playing index by an offset amount. - *

- * Will emit a {@link SelectEvent} if offset is non-zero. - *

- * - * @param offset the offset relative to the current index - */ - public synchronized void offsetIndex(final int offset) { - setIndex(getIndex() + offset); - } - - /** - * Notifies that a change has occurred. - */ - public synchronized void notifyChange() { - broadcast(new AppendEvent(0)); - } - - /** - * Appends the given {@link PlayQueueItem}s to the current play queue. - *

- * If the play queue is shuffled, then append the items to the backup queue as is and - * append the shuffle items to the play queue. - *

- *

- * Will emit a {@link AppendEvent} on any given context. - *

- * - * @param items {@link PlayQueueItem}s to append - */ - public synchronized void append(@NonNull final List items) { - final List itemList = new ArrayList<>(items); - - if (isShuffled()) { - backup.addAll(itemList); - Collections.shuffle(itemList); - } - if (!streams.isEmpty() && streams.get(streams.size() - 1).isAutoQueued() - && !itemList.get(0).isAutoQueued()) { - streams.remove(streams.size() - 1); - } - streams.addAll(itemList); - - broadcast(new AppendEvent(itemList.size())); - } - - /** - * Removes the item at the given index from the play queue. - *

- * The current playing index will decrement if it is greater than the index being removed. - * On cases where the current playing index exceeds the playlist range, it is set to 0. - *

- *

- * Will emit a {@link RemoveEvent} if the index is within the play queue index range. - *

- * - * @param index the index of the item to remove - */ - public synchronized void remove(final int index) { - if (index >= streams.size() || index < 0) { - return; - } - removeInternal(index); - broadcast(new RemoveEvent(index, getIndex())); - } - - /** - * Report an exception for the item at the current index in order and skip to the next one - *

- * This is done as a separate event as the underlying manager may have - * different implementation regarding exceptions. - *

- */ - public synchronized void error() { - final int oldIndex = getIndex(); - queueIndex.incrementAndGet(); - if (streams.size() > queueIndex.get()) { - history.add(streams.get(queueIndex.get())); - } - broadcast(new ErrorEvent(oldIndex, getIndex())); - } - - private synchronized void removeInternal(final int removeIndex) { - final int currentIndex = queueIndex.get(); - final int size = size(); - - if (currentIndex > removeIndex) { - queueIndex.decrementAndGet(); - - } else if (currentIndex >= size) { - queueIndex.set(currentIndex % (size - 1)); - - } else if (currentIndex == removeIndex && currentIndex == size - 1) { - queueIndex.set(0); - } - - if (backup != null) { - backup.remove(getItem(removeIndex)); - } - - history.remove(streams.remove(removeIndex)); - if (streams.size() > queueIndex.get()) { - history.add(streams.get(queueIndex.get())); - } - } - - /** - * Moves a queue item at the source index to the target index. - *

- * If the item being moved is the currently playing, then the current playing index is set - * to that of the target. - * If the moved item is not the currently playing and moves to an index AFTER the - * current playing index, then the current playing index is decremented. - * Vice versa if the an item after the currently playing is moved BEFORE. - *

- * - * @param source the original index of the item - * @param target the new index of the item - */ - public synchronized void move(final int source, final int target) { - if (source < 0 || target < 0) { - return; - } - if (source >= streams.size() || target >= streams.size()) { - return; - } - - final int current = getIndex(); - if (source == current) { - queueIndex.set(target); - } else if (source < current && target >= current) { - queueIndex.decrementAndGet(); - } else if (source > current && target <= current) { - queueIndex.incrementAndGet(); - } - - final PlayQueueItem playQueueItem = streams.remove(source); - playQueueItem.setAutoQueued(false); - streams.add(target, playQueueItem); - broadcast(new MoveEvent(source, target)); - } - - /** - * Sets the recovery record of the item at the index. - *

- * Broadcasts a recovery event. - *

- * - * @param index index of the item - * @param position the recovery position - */ - public synchronized void setRecovery(final int index, final long position) { - if (index < 0 || index >= streams.size()) { - return; - } - - streams.get(index).setRecoveryPosition(position); - broadcast(new RecoveryEvent(index, position)); - } - - /** - * Revoke the recovery record of the item at the index. - *

- * Broadcasts a recovery event. - *

- * - * @param index index of the item - */ - public synchronized void unsetRecovery(final int index) { - setRecovery(index, PlayQueueItem.RECOVERY_UNSET); - } - - /** - * Shuffles the current play queue - *

- * This method first backs up the existing play queue and item being played. Then a newly - * shuffled play queue will be generated along with currently playing item placed at the - * beginning of the queue. This item will also be added to the history. - *

- *

- * Will emit a {@link ReorderEvent} if shuffled. - *

- * - * @implNote Does nothing if the queue has a size <= 2 (the currently playing video must stay on - * top, so shuffling a size-2 list does nothing) - */ - public synchronized void shuffle() { - // Create a backup if it doesn't already exist - // Note: The backup-list has to be created at all cost (even when size <= 2). - // Otherwise it's not possible to enter shuffle-mode! - if (backup == null) { - backup = new ArrayList<>(streams); - } - // Can't shuffle a list that's empty or only has one element - if (size() <= 2) { - return; - } - - final int originalIndex = getIndex(); - final PlayQueueItem currentItem = getItem(); - - Collections.shuffle(streams); - - // Move currentItem to the head of the queue - streams.remove(currentItem); - streams.add(0, currentItem); - queueIndex.set(0); - - history.add(currentItem); - - broadcast(new ReorderEvent(originalIndex, 0)); - } - - /** - * Unshuffles the current play queue if a backup play queue exists. - *

- * This method undoes shuffling and index will be set to the previously playing item if found, - * otherwise, the index will reset to 0. - *

- *

- * Will emit a {@link ReorderEvent} if a backup exists. - *

- */ - public synchronized void unshuffle() { - if (backup == null) { - return; - } - final int originIndex = getIndex(); - final PlayQueueItem current = getItem(); - - streams = backup; - backup = null; - - final int newIndex = streams.indexOf(current); - if (newIndex != -1) { - queueIndex.set(newIndex); - } else { - queueIndex.set(0); - } - if (streams.size() > queueIndex.get()) { - history.add(streams.get(queueIndex.get())); - } - - broadcast(new ReorderEvent(originIndex, queueIndex.get())); - } - - /** - * Selects previous played item. - * - * This method removes currently playing item from history and - * starts playing the last item from history if it exists - * - * @return true if history is not empty and the item can be played - * */ - public synchronized boolean previous() { - if (history.size() <= 1) { - return false; - } - - history.remove(history.size() - 1); - - final PlayQueueItem last = history.remove(history.size() - 1); - setIndex(indexOf(last)); - - return true; - } - - /* - * Compares two PlayQueues. Useful when a user switches players but queue is the same so - * we don't have to do anything with new queue. - * This method also gives a chance to track history of items in a queue in - * VideoDetailFragment without duplicating items from two identical queues - */ - public boolean equalStreams(@Nullable final PlayQueue other) { - if (other == null) { - return false; - } - if (size() != other.size()) { - return false; - } - for (int i = 0; i < size(); i++) { - final PlayQueueItem stream = streams.get(i); - final PlayQueueItem otherStream = other.streams.get(i); - // Check is based on serviceId and URL - if (stream.getServiceId() != otherStream.getServiceId() - || !stream.getUrl().equals(otherStream.getUrl())) { - return false; - } - } - return true; - } - - public boolean equalStreamsAndIndex(@Nullable final PlayQueue other) { - if (equalStreams(other)) { - //noinspection ConstantConditions - return other.getIndex() == getIndex(); //NOSONAR: other is not null - } - return false; - } - - public boolean isDisposed() { - return disposed; - } - /*////////////////////////////////////////////////////////////////////////// - // Rx Broadcast - //////////////////////////////////////////////////////////////////////////*/ - - private void broadcast(@NonNull final PlayQueueEvent event) { - if (eventBroadcast != null) { - eventBroadcast.onNext(event); - } - } -} - diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.kt b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.kt new file mode 100644 index 000000000..1ae7e5cdb --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.kt @@ -0,0 +1,497 @@ +package org.schabi.newpipe.player.playqueue + +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.BackpressureStrategy +import io.reactivex.rxjava3.core.Flowable +import io.reactivex.rxjava3.subjects.BehaviorSubject +import org.schabi.newpipe.player.playqueue.events.AppendEvent +import org.schabi.newpipe.player.playqueue.events.ErrorEvent +import org.schabi.newpipe.player.playqueue.events.InitEvent +import org.schabi.newpipe.player.playqueue.events.MoveEvent +import org.schabi.newpipe.player.playqueue.events.PlayQueueEvent +import org.schabi.newpipe.player.playqueue.events.RecoveryEvent +import org.schabi.newpipe.player.playqueue.events.RemoveEvent +import org.schabi.newpipe.player.playqueue.events.ReorderEvent +import org.schabi.newpipe.player.playqueue.events.SelectEvent +import java.io.Serializable +import java.util.Collections +import java.util.concurrent.atomic.AtomicInteger + +/** + * PlayQueue is responsible for keeping track of a list of streams and the index of + * the stream that should be currently playing. + * + * This class contains basic manipulation of a playlist while also functions as a + * message bus, providing all listeners with new updates to the play queue. + * + * This class can be serialized for passing intents, but in order to start the + * message bus, it must be initialized. + */ +abstract class PlayQueue internal constructor( + index: Int, + startWith: List, +) : Serializable { + private val queueIndex = AtomicInteger(index) + private val history = mutableListOf() + private var backup = mutableListOf() + private var streams = startWith.toMutableList() + + @Transient + private var eventBroadcast: BehaviorSubject? = null + + /** + * Returns the play queue's update broadcast. + * May be null if the play queue message bus is not initialized. + * + * @return the play queue's update broadcast + */ + @Transient + var broadcastReceiver: Flowable? = null + private set + + @Transient + var isDisposed: Boolean = false + private set + + init { + if (streams.size > index) { + history.add(streams[index]) + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Playlist actions + ////////////////////////////////////////////////////////////////////////// */ + + /** + * Initializes the play queue message buses. + * + * Also starts a self reporter for logging if debug mode is enabled. + */ + fun init() { + eventBroadcast = BehaviorSubject.create() + + broadcastReceiver = + eventBroadcast!! + .toFlowable(BackpressureStrategy.BUFFER) + .observeOn(AndroidSchedulers.mainThread()) + .startWithItem(InitEvent()) + } + + /** + * Dispose the play queue by stopping all message buses. + */ + open fun dispose() { + eventBroadcast?.onComplete() + eventBroadcast = null + broadcastReceiver = null + this.isDisposed = true + } + + /** + * Checks if the queue is complete. + * + * A queue is complete if it has loaded all items in an external playlist + * single stream or local queues are always complete. + * + * @return whether the queue is complete + */ + abstract val isComplete: Boolean + + /** + * Load partial queue in the background, does nothing if the queue is complete. + */ + abstract fun fetch() + + /*////////////////////////////////////////////////////////////////////////// + // Readonly ops + ////////////////////////////////////////////////////////////////////////// */ + @set:Synchronized + var index: Int = 0 + /** + * @return the current index that should be played + */ + get() = queueIndex.get() + + /** + * Changes the current playing index to a new index. + * + * This method is guarded using in a circular manner for index exceeding the play queue size. + * + * Will emit a [SelectEvent] if the index is not the current playing index. + * + * @param index the index to be set + */ + set(index) { + val oldIndex = field + + val newIndex: Int + + if (index < 0) { + newIndex = 0 + } else if (index < streams.size) { + // Regular assignment for index in bounds + newIndex = index + } else if (streams.isEmpty()) { + // Out of bounds from here on + // Need to check if stream is empty to prevent arithmetic error and negative index + newIndex = 0 + } else if (this.isComplete) { + // Circular indexing + newIndex = index % streams.size + } else { + // Index of last element + newIndex = streams.size - 1 + } + + queueIndex.set(newIndex) + + if (oldIndex != newIndex) { + history.add(streams[newIndex]) + } + + /* + TODO: Documentation states that a SelectEvent will only be emitted if the new index is... + different from the old one but this is emitted regardless? Not sure what this what it does + exactly so I won't touch it + */ + broadcast(SelectEvent(oldIndex, newIndex)) + } + + /** + * @return the current item that should be played, or null if the queue is empty + */ + val item get() = getItem(this.index) + + /** + * @param index the index of the item to return + * @return the item at the given index, or null if the index is out of bounds + */ + fun getItem(index: Int) = streams.getOrNull(index) + + /** + * Returns the index of the given item using referential equality. + * May be null despite play queue contains identical item. + * + * @param item the item to find the index of + * @return the index of the given item + */ + fun indexOf(item: PlayQueueItem): Int = streams.indexOf(item) + + /** + * @return the current size of play queue. + */ + fun size(): Int = streams.size + + /** + * Checks if the play queue is empty. + * + * @return whether the play queue is empty + */ + val isEmpty: Boolean + get() = streams.isEmpty() + + /** + * Determines if the current play queue is shuffled. + * + * @return whether the play queue is shuffled + */ + val isShuffled: Boolean + get() = backup.isNotEmpty() + + /** + * @return an immutable view of the play queue + */ + fun getStreams(): List = Collections.unmodifiableList(streams) + + /*////////////////////////////////////////////////////////////////////////// + // Write ops + ////////////////////////////////////////////////////////////////////////// */ + + /** + * Changes the current playing index by an offset amount. + * + * Will emit a [SelectEvent] if offset is non-zero. + * + * @param offset the offset relative to the current index + */ + @Synchronized + fun offsetIndex(offset: Int) { + this.index += offset + } + + /** + * Notifies that a change has occurred. + */ + @Synchronized + fun notifyChange() { + broadcast(AppendEvent(0)) + } + + /** + * Appends the given [PlayQueueItem]s to the current play queue. + * + * If the play queue is shuffled, then append the items to the backup queue as is and + * append the shuffle items to the play queue. + * + * Will emit a [AppendEvent] on any given context. + * + * @param items [PlayQueueItem]s to append + */ + @Synchronized + fun append(items: List) { + val itemList = items.toMutableList() + + if (this.isShuffled) { + backup.addAll(itemList) + itemList.shuffle() + } + if (!streams.isEmpty() && streams.last().isAutoQueued && !itemList[0].isAutoQueued) { + streams.removeAt(streams.lastIndex) + } + streams.addAll(itemList) + + broadcast(AppendEvent(itemList.size)) + } + + /** + * Removes the item at the given index from the play queue. + * + * The current playing index will decrement if it is greater than the index being removed. + * On cases where the current playing index exceeds the playlist range, it is set to 0. + * + * Will emit a [RemoveEvent] if the index is within the play queue index range. + * + * @param index the index of the item to remove + */ + @Synchronized + fun remove(index: Int) { + if (index >= streams.size || index < 0) { + return + } + removeInternal(index) + broadcast(RemoveEvent(index, this.index)) + } + + /** + * Report an exception for the item at the current index in order and skip to the next one + * + * This is done as a separate event as the underlying manager may have + * different implementation regarding exceptions. + */ + @Synchronized + fun error() { + val oldIndex = this.index + queueIndex.incrementAndGet() + if (streams.size > queueIndex.get()) { + history.add(streams[queueIndex.get()]) + } + broadcast(ErrorEvent(oldIndex, this.index)) + } + + @Synchronized + private fun removeInternal(removeIndex: Int) { + val currentIndex = queueIndex.get() + val size = size() + + if (currentIndex > removeIndex) { + queueIndex.decrementAndGet() + } else if (currentIndex >= size) { + queueIndex.set(currentIndex % (size - 1)) + } else if (currentIndex == removeIndex && currentIndex == size - 1) { + queueIndex.set(0) + } + + backup.remove(getItem(removeIndex)!!) + + history.remove(streams.removeAt(removeIndex)) + if (streams.size > queueIndex.get()) { + history.add(streams[queueIndex.get()]) + } + } + + /** + * Moves a queue item at the source index to the target index. + * + * If the item being moved is the currently playing, then the current playing index is set + * to that of the target. + * If the moved item is not the currently playing and moves to an index **AFTER** the + * current playing index, then the current playing index is decremented. + * Vice versa if the an item after the currently playing is moved **BEFORE**. + * + * @param source the original index of the item + * @param target the new index of the item + */ + @Synchronized + fun move( + source: Int, + target: Int, + ) { + if (source < 0 || target < 0) { + return + } + if (source >= streams.size || target >= streams.size) { + return + } + + val current = this.index + if (source == current) { + queueIndex.set(target) + } else if (source < current && target >= current) { + queueIndex.decrementAndGet() + } else if (source > current && target <= current) { + queueIndex.incrementAndGet() + } + + val playQueueItem = streams.removeAt(source) + playQueueItem.isAutoQueued = false + streams.add(target, playQueueItem) + broadcast(MoveEvent(source, target)) + } + + /** + * Sets the recovery record of the item at the index. + * + * Broadcasts a recovery event. + * + * @param index index of the item + * @param position the recovery position + */ + @Synchronized + fun setRecovery( + index: Int, + position: Long, + ) { + streams.getOrNull(index)?.let { + it.recoveryPosition = position + broadcast(RecoveryEvent(index, position)) + } + } + + /** + * Revoke the recovery record of the item at the index. + * + * Broadcasts a recovery event. + * + * @param index index of the item + */ + @Synchronized + fun unsetRecovery(index: Int) { + setRecovery(index, Long.Companion.MIN_VALUE) + } + + /** + * Shuffles the current play queue + * + * This method first backs up the existing play queue and item being played. Then a newly + * shuffled play queue will be generated along with currently playing item placed at the + * beginning of the queue. This item will also be added to the history. + * + * Will emit a [ReorderEvent] if shuffled. + * + * @implNote Does nothing if the queue has a size <= 2 (the currently playing video must stay on + * top, so shuffling a size-2 list does nothing) + */ + @Synchronized + fun shuffle() { + // Create a backup if it doesn't already exist + // Note: The backup-list has to be created at all cost (even when size <= 2). + // Otherwise it's not possible to enter shuffle-mode! + if (backup.isEmpty()) { + backup = streams.toMutableList() + } + // Can't shuffle a list that's empty or only has one element + if (size() <= 2) { + return + } + + val originalIndex = this.index + val currentItem = this.item + + streams.shuffle() + + // Move currentItem to the head of the queue + streams.remove(currentItem!!) + streams.add(0, currentItem) + queueIndex.set(0) + + history.add(currentItem) + + broadcast(ReorderEvent(originalIndex, 0)) + } + + /** + * Unshuffles the current play queue if a backup play queue exists. + * + * This method undoes shuffling and index will be set to the previously playing item if found, + * otherwise, the index will reset to 0. + * + * Will emit a [ReorderEvent] if a backup exists. + */ + @Synchronized + fun unshuffle() { + if (backup.isEmpty()) { + return + } + val originIndex = this.index + val current = this.item + + streams = backup + backup = mutableListOf() + + val newIndex = streams.indexOf(current!!) + if (newIndex != -1) { + queueIndex.set(newIndex) + } else { + queueIndex.set(0) + } + if (streams.size > queueIndex.get()) { + history.add(streams[queueIndex.get()]) + } + + broadcast(ReorderEvent(originIndex, queueIndex.get())) + } + + /** + * Selects previous played item. + * + * This method removes currently playing item from history and + * starts playing the last item from history if it exists + * + * @return true if history is not empty and the item can be played + */ + @Synchronized + fun previous(): Boolean { + if (history.size <= 1) { + return false + } + + history.removeAt(history.size - 1) + + val last = history.removeAt(history.size - 1) + this.index = indexOf(last) + + return true + } + + /* + * Compares two PlayQueues. Useful when a user switches players but queue is the same so + * we don't have to do anything with new queue. + * This method also gives a chance to track history of items in a queue in + * VideoDetailFragment without duplicating items from two identical queues + */ + override fun equals(o: Any?): Boolean = o is PlayQueue && streams == o.streams + + override fun hashCode(): Int = streams.hashCode() + + fun equalStreamsAndIndex(other: PlayQueue?): Boolean { + return equals(other) && other!!.index == this.index // NOSONAR: other is not null + } + + /*////////////////////////////////////////////////////////////////////////// + // Rx Broadcast + ////////////////////////////////////////////////////////////////////////// */ + private fun broadcast(event: PlayQueueEvent) { + eventBroadcast?.onNext(event) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.java deleted file mode 100644 index 759c51267..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.java +++ /dev/null @@ -1,142 +0,0 @@ -package org.schabi.newpipe.player.playqueue; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.schabi.newpipe.extractor.Image; -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import org.schabi.newpipe.extractor.stream.StreamType; -import org.schabi.newpipe.util.ExtractorHelper; - -import java.io.Serializable; -import java.util.List; - -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.schedulers.Schedulers; - -public class PlayQueueItem implements Serializable { - public static final long RECOVERY_UNSET = Long.MIN_VALUE; - private static final String EMPTY_STRING = ""; - - @NonNull - private final String title; - @NonNull - private final String url; - private final int serviceId; - private final long duration; - @NonNull - private final List thumbnails; - @NonNull - private final String uploader; - private final String uploaderUrl; - @NonNull - private final StreamType streamType; - - private boolean isAutoQueued; - - private long recoveryPosition; - private Throwable error; - - PlayQueueItem(@NonNull final StreamInfo info) { - this(info.getName(), info.getUrl(), info.getServiceId(), info.getDuration(), - info.getThumbnails(), info.getUploaderName(), - info.getUploaderUrl(), info.getStreamType()); - - if (info.getStartPosition() > 0) { - setRecoveryPosition(info.getStartPosition() * 1000); - } - } - - PlayQueueItem(@NonNull final StreamInfoItem item) { - this(item.getName(), item.getUrl(), item.getServiceId(), item.getDuration(), - item.getThumbnails(), item.getUploaderName(), - item.getUploaderUrl(), item.getStreamType()); - } - - @SuppressWarnings("ParameterNumber") - private PlayQueueItem(@Nullable final String name, @Nullable final String url, - final int serviceId, final long duration, - final List thumbnails, @Nullable final String uploader, - final String uploaderUrl, @NonNull final StreamType streamType) { - this.title = name != null ? name : EMPTY_STRING; - this.url = url != null ? url : EMPTY_STRING; - this.serviceId = serviceId; - this.duration = duration; - this.thumbnails = thumbnails; - this.uploader = uploader != null ? uploader : EMPTY_STRING; - this.uploaderUrl = uploaderUrl; - this.streamType = streamType; - - this.recoveryPosition = RECOVERY_UNSET; - } - - @NonNull - public String getTitle() { - return title; - } - - @NonNull - public String getUrl() { - return url; - } - - public int getServiceId() { - return serviceId; - } - - public long getDuration() { - return duration; - } - - @NonNull - public List getThumbnails() { - return thumbnails; - } - - @NonNull - public String getUploader() { - return uploader; - } - - public String getUploaderUrl() { - return uploaderUrl; - } - - @NonNull - public StreamType getStreamType() { - return streamType; - } - - public long getRecoveryPosition() { - return recoveryPosition; - } - - /*package-private*/ void setRecoveryPosition(final long recoveryPosition) { - this.recoveryPosition = recoveryPosition; - } - - @Nullable - public Throwable getError() { - return error; - } - - @NonNull - public Single getStream() { - return ExtractorHelper.getStreamInfo(this.serviceId, this.url, false) - .subscribeOn(Schedulers.io()) - .doOnError(throwable -> error = throwable); - } - - public boolean isAutoQueued() { - return isAutoQueued; - } - - //////////////////////////////////////////////////////////////////////////// - // Item States, keep external access out - //////////////////////////////////////////////////////////////////////////// - - public void setAutoQueued(final boolean autoQueued) { - isAutoQueued = autoQueued; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.kt b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.kt new file mode 100644 index 000000000..d6b4b0402 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.kt @@ -0,0 +1,71 @@ +package org.schabi.newpipe.player.playqueue + +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import org.schabi.newpipe.extractor.Image +import org.schabi.newpipe.extractor.stream.StreamInfo +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.extractor.stream.StreamType +import org.schabi.newpipe.util.ExtractorHelper +import java.io.Serializable +import java.util.Objects + +class PlayQueueItem private constructor( + val title: String, + val url: String, + val serviceId: Int, + val duration: Long, + val thumbnails: List, + val uploader: String, + val uploaderUrl: String?, + val streamType: StreamType, +) : Serializable { + // + // ////////////////////////////////////////////////////////////////////// */ + // Item States, keep external access out + // + // ////////////////////////////////////////////////////////////////////// */ + var isAutoQueued: Boolean = false + + // package-private + var recoveryPosition = Long.Companion.MIN_VALUE + var error: Throwable? = null + private set + + constructor(info: StreamInfo) : this( + info.name.orEmpty(), + info.url.orEmpty(), + info.serviceId, + info.duration, + info.thumbnails, + info.uploaderName.orEmpty(), + info.uploaderUrl, + info.streamType, + ) { + if (info.startPosition > 0) { + this.recoveryPosition = info.startPosition * 1000 + } + } + + constructor(item: StreamInfoItem) : this( + item.name.orEmpty(), + item.url.orEmpty(), + item.serviceId, + item.duration, + item.thumbnails, + item.uploaderName.orEmpty(), + item.uploaderUrl, + item.streamType, + ) + + val stream: Single + get() = + ExtractorHelper + .getStreamInfo(serviceId, url, false) + .subscribeOn(Schedulers.io()) + .doOnError { throwable -> error = throwable } + + override fun equals(o: Any?) = o is PlayQueueItem && serviceId == o.serviceId && url == o.url + + override fun hashCode() = Objects.hash(url, serviceId) +} diff --git a/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.kt b/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.kt index 190da81e6..5419027a5 100644 --- a/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.kt +++ b/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.kt @@ -1,25 +1,20 @@ package org.schabi.newpipe.player.ui import org.schabi.newpipe.util.GuardedByMutex -import java.util.Optional +import kotlin.reflect.KClass +import kotlin.reflect.safeCast +/** + * Creates a [PlayerUiList] starting with the provided player uis. The provided player uis + * will not be prepared like those passed to [.addAndPrepare], because when + * the [PlayerUiList] constructor is called, the player is still not running and it + * wouldn't make sense to initialize uis then. Instead the player will initialize them by doing + * proper calls to [.call]. + * + * @param initialPlayerUis the player uis this list should start with; the order will be kept + */ class PlayerUiList(vararg initialPlayerUis: PlayerUi) { - private val playerUis = GuardedByMutex(mutableListOf()) - - /** - * Creates a [PlayerUiList] starting with the provided player uis. The provided player uis - * will not be prepared like those passed to [.addAndPrepare], because when - * the [PlayerUiList] constructor is called, the player is still not running and it - * wouldn't make sense to initialize uis then. Instead the player will initialize them by doing - * proper calls to [.call]. - * - * @param initialPlayerUis the player uis this list should start with; the order will be kept - */ - init { - playerUis.runWithLockSync { - lockData.addAll(listOf(*initialPlayerUis)) - } - } + private val playerUis = GuardedByMutex(mutableListOf(*initialPlayerUis)) /** * Adds the provided player ui to the list and calls on it the initialization functions that @@ -83,30 +78,22 @@ class PlayerUiList(vararg initialPlayerUis: PlayerUi) { * @param T the class type parameter * @return the first player UI of the required type found in the list, or null */ - fun get(playerUiType: Class): T? = + fun get(playerUiType: KClass): T? = playerUis.runWithLockSync { for (ui in lockData) { if (playerUiType.isInstance(ui)) { - when (val r = playerUiType.cast(ui)) { - // try all UIs before returning null - null -> continue - else -> return@runWithLockSync r - } + // try all UIs before returning null + playerUiType.safeCast(ui)?.let { return@runWithLockSync it } } } return@runWithLockSync null } /** - * @param playerUiType the class of the player UI to return; - * the [Class.isInstance] method will be used, so even subclasses could be returned - * @param T the class type parameter - * @return the first player UI of the required type found in the list, or an empty - * [Optional] otherwise - */ - @Deprecated("use get", ReplaceWith("get(playerUiType)")) - fun getOpt(playerUiType: Class): Optional = - Optional.ofNullable(get(playerUiType)) + * See [get] above + */ + fun get(playerUiType: Class): T? = + get(playerUiType.kotlin) /** * Calls the provided consumer on all player UIs in the list, in order of addition. diff --git a/app/src/main/java/org/schabi/newpipe/settings/BackupRestoreSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/BackupRestoreSettingsFragment.java index 97df1549b..e794ace72 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/BackupRestoreSettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/BackupRestoreSettingsFragment.java @@ -1,7 +1,6 @@ package org.schabi.newpipe.settings; import static org.schabi.newpipe.extractor.utils.Utils.isBlank; -import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; import android.app.Activity; import android.app.AlertDialog; @@ -17,7 +16,6 @@ import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.core.content.ContextCompat; import androidx.preference.Preference; import androidx.preference.PreferenceManager; @@ -35,12 +33,10 @@ import org.schabi.newpipe.streams.io.StoredFileHelper; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.ZipHelper; -import java.io.File; import java.io.IOException; import java.text.SimpleDateFormat; import java.util.Date; import java.util.Locale; -import java.util.Objects; public class BackupRestoreSettingsFragment extends BasePreferenceFragment { @@ -61,13 +57,10 @@ public class BackupRestoreSettingsFragment extends BasePreferenceFragment { @Override public void onCreatePreferences(@Nullable final Bundle savedInstanceState, @Nullable final String rootKey) { - final File homeDir = ContextCompat.getDataDir(requireContext()); - Objects.requireNonNull(homeDir); - manager = new ImportExportManager(new BackupFileLocator(homeDir)); + manager = new ImportExportManager(new BackupFileLocator(requireContext())); importExportDataPathKey = getString(R.string.import_export_data_path); - addPreferencesFromResourceRegistry(); final Preference importDataPreference = requirePreference(R.string.import_data); @@ -126,7 +119,6 @@ public class BackupRestoreSettingsFragment extends BasePreferenceFragment { } private void requestExportPathResult(final ActivityResult result) { - assureCorrectAppLanguage(requireContext()); if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) { // will be saved only on success final Uri lastExportDataUri = result.getData().getData(); @@ -139,7 +131,6 @@ public class BackupRestoreSettingsFragment extends BasePreferenceFragment { } private void requestImportPathResult(final ActivityResult result) { - assureCorrectAppLanguage(requireContext()); if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) { // will be saved only on success final Uri lastImportDataUri = result.getData().getData(); @@ -183,9 +174,7 @@ public class BackupRestoreSettingsFragment extends BasePreferenceFragment { } try { - if (!manager.ensureDbDirectoryExists()) { - throw new IOException("Could not create databases dir"); - } + manager.ensureDbDirectoryExists(); // replace the current database if (!manager.extractDb(file)) { diff --git a/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java index b998b6337..7e1f22776 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java @@ -10,13 +10,14 @@ import android.util.Log; import android.widget.Toast; import androidx.appcompat.app.AppCompatDelegate; +import androidx.core.os.LocaleListCompat; import androidx.preference.Preference; import org.schabi.newpipe.DownloaderImpl; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.NewPipe; -import org.schabi.newpipe.extractor.localization.ContentCountry; -import org.schabi.newpipe.extractor.localization.Localization; +import org.schabi.newpipe.player.helper.PlayerHelper; +import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.image.ImageStrategy; import org.schabi.newpipe.util.image.PreferredImageQuality; @@ -27,26 +28,27 @@ import coil3.SingletonImageLoader; public class ContentSettingsFragment extends BasePreferenceFragment { private String youtubeRestrictedModeEnabledKey; - private String initialLanguage; - @Override public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { youtubeRestrictedModeEnabledKey = getString(R.string.youtube_restricted_mode_enabled); addPreferencesFromResourceRegistry(); - initialLanguage = defaultPreferences.getString(getString(R.string.app_language_key), "en"); + setupAppLanguagePreferences(); + setupImageQualityPref(); + } + + private void setupAppLanguagePreferences() { + final Preference appLanguagePref = requirePreference(R.string.app_language_key); + // Android 13+ allows to set app specific languages + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + appLanguagePref.setVisible(false); - if (Build.VERSION.SDK_INT >= 33) { - requirePreference(R.string.app_language_key).setVisible(false); final Preference newAppLanguagePref = requirePreference(R.string.app_language_android_13_and_up_key); newAppLanguagePref.setSummaryProvider(preference -> { - final Locale customLocale = AppCompatDelegate.getApplicationLocales().get(0); - if (customLocale != null) { - return customLocale.getDisplayName(); - } - return getString(R.string.systems_language); + final Locale loc = AppCompatDelegate.getApplicationLocales().get(0); + return loc != null ? loc.getDisplayName() : getString(R.string.systems_language); }); newAppLanguagePref.setOnPreferenceClickListener(preference -> { final Intent intent = new Intent(Settings.ACTION_APP_LOCALE_SETTINGS) @@ -55,22 +57,32 @@ public class ContentSettingsFragment extends BasePreferenceFragment { return true; }); newAppLanguagePref.setVisible(true); + return; } - final Preference imageQualityPreference = requirePreference(R.string.image_quality_key); - imageQualityPreference.setOnPreferenceChangeListener( - (preference, newValue) -> { - ImageStrategy.setPreferredImageQuality(PreferredImageQuality - .fromPreferenceKey(requireContext(), (String) newValue)); - final var loader = SingletonImageLoader.get(preference.getContext()); - loader.getMemoryCache().clear(); - loader.getDiskCache().clear(); - Toast.makeText(preference.getContext(), - R.string.thumbnail_cache_wipe_complete_notice, Toast.LENGTH_SHORT) - .show(); + appLanguagePref.setOnPreferenceChangeListener((preference, newValue) -> { + final String language = (String) newValue; + final String systemLang = getString(R.string.default_localization_key); + final String tag = systemLang.equals(language) ? null : language; + AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags(tag)); + return true; + }); + } - return true; - }); + private void setupImageQualityPref() { + requirePreference(R.string.image_quality_key).setOnPreferenceChangeListener( + (preference, newValue) -> { + ImageStrategy.setPreferredImageQuality(PreferredImageQuality + .fromPreferenceKey(requireContext(), (String) newValue)); + final var loader = SingletonImageLoader.get(preference.getContext()); + loader.getMemoryCache().clear(); + loader.getDiskCache().clear(); + Toast.makeText(preference.getContext(), + R.string.thumbnail_cache_wipe_complete_notice, Toast.LENGTH_SHORT) + .show(); + + return true; + }); } @Override @@ -91,22 +103,10 @@ public class ContentSettingsFragment extends BasePreferenceFragment { public void onDestroy() { super.onDestroy(); - final String selectedLanguage = - defaultPreferences.getString(getString(R.string.app_language_key), "en"); - - if (!selectedLanguage.equals(initialLanguage)) { - if (Build.VERSION.SDK_INT < 33) { - Toast.makeText( - requireContext(), - R.string.localization_changes_requires_app_restart, - Toast.LENGTH_LONG - ).show(); - } - final Localization selectedLocalization = org.schabi.newpipe.util.Localization - .getPreferredLocalization(requireContext()); - final ContentCountry selectedContentCountry = org.schabi.newpipe.util.Localization - .getPreferredContentCountry(requireContext()); - NewPipe.setupLocalization(selectedLocalization, selectedContentCountry); - } + final Context context = requireContext(); + NewPipe.setupLocalization( + Localization.getPreferredLocalization(context), + Localization.getPreferredContentCountry(context)); + PlayerHelper.resetFormat(); } } diff --git a/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java index ff7811af3..356dcd9b2 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java @@ -1,7 +1,5 @@ package org.schabi.newpipe.settings; -import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; - import android.app.Activity; import android.content.ContentResolver; import android.content.Context; @@ -209,8 +207,6 @@ public class DownloadSettingsFragment extends BasePreferenceFragment { } private void requestDownloadPathResult(final ActivityResult result, final String key) { - assureCorrectAppLanguage(getContext()); - if (result.getResultCode() != Activity.RESULT_OK) { return; } diff --git a/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java b/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java index 9fe5240cc..5daa3ad82 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java +++ b/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java @@ -13,6 +13,7 @@ import androidx.preference.PreferenceManager; import org.schabi.newpipe.App; import org.schabi.newpipe.R; +import org.schabi.newpipe.settings.migration.MigrationManager; import org.schabi.newpipe.util.DeviceUtils; import java.io.File; @@ -46,7 +47,7 @@ public final class NewPipeSettings { public static void initSettings(final Context context) { // first run migrations, then setDefaultValues, since the latter requires the correct types - SettingMigrations.runMigrationsIfNeeded(context); + MigrationManager.runMigrationsIfNeeded(context); // readAgain is true so that if new settings are added their default value is set PreferenceManager.setDefaultValues(context, R.xml.main_settings, true); diff --git a/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java b/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java index 0d57ce174..d5089cb7d 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java +++ b/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java @@ -1,7 +1,5 @@ package org.schabi.newpipe.settings; -import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; - import android.content.Context; import android.os.Bundle; import android.text.TextUtils; @@ -89,7 +87,6 @@ public class SettingsActivity extends AppCompatActivity implements @Override protected void onCreate(final Bundle savedInstanceBundle) { setTheme(ThemeHelper.getSettingsThemeStyle(this)); - assureCorrectAppLanguage(this); super.onCreate(savedInstanceBundle); Bridge.restoreInstanceState(this, savedInstanceBundle); @@ -228,7 +225,6 @@ public class SettingsActivity extends AppCompatActivity implements // Build search items final Context searchContext = getApplicationContext(); - assureCorrectAppLanguage(searchContext); final PreferenceParser parser = new PreferenceParser(searchContext, config); final PreferenceSearcher searcher = new PreferenceSearcher(config); diff --git a/app/src/main/java/org/schabi/newpipe/settings/export/BackupFileLocator.kt b/app/src/main/java/org/schabi/newpipe/settings/export/BackupFileLocator.kt index c864e4a0d..38227e10e 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/export/BackupFileLocator.kt +++ b/app/src/main/java/org/schabi/newpipe/settings/export/BackupFileLocator.kt @@ -1,11 +1,13 @@ package org.schabi.newpipe.settings.export -import java.io.File +import android.content.Context +import java.nio.file.Path +import kotlin.io.path.div /** * Locates specific files of NewPipe based on the home directory of the app. */ -class BackupFileLocator(private val homeDir: File) { +class BackupFileLocator(context: Context) { companion object { const val FILE_NAME_DB = "newpipe.db" @Deprecated( @@ -16,13 +18,8 @@ class BackupFileLocator(private val homeDir: File) { const val FILE_NAME_JSON_PREFS = "preferences.json" } - val dbDir by lazy { File(homeDir, "/databases") } - - val db by lazy { File(dbDir, FILE_NAME_DB) } - - val dbJournal by lazy { File(dbDir, "$FILE_NAME_DB-journal") } - - val dbShm by lazy { File(dbDir, "$FILE_NAME_DB-shm") } - - val dbWal by lazy { File(dbDir, "$FILE_NAME_DB-wal") } + val db: Path = context.getDatabasePath(FILE_NAME_DB).toPath() + val dbJournal: Path = db.resolveSibling("$FILE_NAME_DB-journal") + val dbShm: Path = db.resolveSibling("$FILE_NAME_DB-shm") + val dbWal: Path = db.resolveSibling("$FILE_NAME_DB-wal") } diff --git a/app/src/main/java/org/schabi/newpipe/settings/export/ImportExportManager.kt b/app/src/main/java/org/schabi/newpipe/settings/export/ImportExportManager.kt index 36e0b9ce1..cbf860d2c 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/export/ImportExportManager.kt +++ b/app/src/main/java/org/schabi/newpipe/settings/export/ImportExportManager.kt @@ -12,6 +12,8 @@ import java.io.FileNotFoundException import java.io.IOException import java.io.ObjectOutputStream import java.util.zip.ZipOutputStream +import kotlin.io.path.createParentDirectories +import kotlin.io.path.deleteIfExists class ImportExportManager(private val fileLocator: BackupFileLocator) { companion object { @@ -28,11 +30,8 @@ class ImportExportManager(private val fileLocator: BackupFileLocator) { // previous file size, the file will retain part of the previous content and be corrupted ZipOutputStream(SharpOutputStream(file.openAndTruncateStream()).buffered()).use { outZip -> // add the database - ZipHelper.addFileToZip( - outZip, - BackupFileLocator.FILE_NAME_DB, - fileLocator.db.path, - ) + val name = BackupFileLocator.FILE_NAME_DB + ZipHelper.addFileToZip(outZip, name, fileLocator.db) // add the legacy vulnerable serialized preferences (will be removed in the future) ZipHelper.addFileToZip( @@ -61,11 +60,10 @@ class ImportExportManager(private val fileLocator: BackupFileLocator) { /** * Tries to create database directory if it does not exist. - * - * @return Whether the directory exists afterwards. */ - fun ensureDbDirectoryExists(): Boolean { - return fileLocator.dbDir.exists() || fileLocator.dbDir.mkdir() + @Throws(IOException::class) + fun ensureDbDirectoryExists() { + fileLocator.db.createParentDirectories() } /** @@ -75,16 +73,13 @@ class ImportExportManager(private val fileLocator: BackupFileLocator) { * @return true if the database was successfully extracted, false otherwise */ fun extractDb(file: StoredFileHelper): Boolean { - val success = ZipHelper.extractFileFromZip( - file, - BackupFileLocator.FILE_NAME_DB, - fileLocator.db.path, - ) + val name = BackupFileLocator.FILE_NAME_DB + val success = ZipHelper.extractFileFromZip(file, name, fileLocator.db) if (success) { - fileLocator.dbJournal.delete() - fileLocator.dbWal.delete() - fileLocator.dbShm.delete() + fileLocator.dbJournal.deleteIfExists() + fileLocator.dbWal.deleteIfExists() + fileLocator.dbShm.deleteIfExists() } return success diff --git a/app/src/main/java/org/schabi/newpipe/settings/migration/MigrationManager.java b/app/src/main/java/org/schabi/newpipe/settings/migration/MigrationManager.java new file mode 100644 index 000000000..d5b0e783d --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/migration/MigrationManager.java @@ -0,0 +1,103 @@ +package org.schabi.newpipe.settings.migration; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.core.util.Consumer; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.error.ErrorUtil; + +import java.util.ArrayList; +import java.util.List; + +/** + * MigrationManager is responsible for running migrations and showing the user information about + * the migrations that were applied. + */ +public final class MigrationManager { + + private static final String TAG = MigrationManager.class.getSimpleName(); + /** + * List of UI actions that are performed after the UI is initialized (e.g. showing alert + * dialogs) to inform the user about changes that were applied by migrations. + */ + private static final List> MIGRATION_INFO = new ArrayList<>(); + + private MigrationManager() { + // MigrationManager is a utility class that is completely static + } + + /** + * Run all migrations that are needed for the current version of NewPipe. + * This method should be called at the start of the application, before any other operations + * that depend on the settings. + * + * @param context Context that can be used to run migrations + */ + public static void runMigrationsIfNeeded(@NonNull final Context context) { + SettingMigrations.runMigrationsIfNeeded(context); + } + + /** + * Perform UI actions informing about migrations that took place if they are present. + * @param context Context that can be used to show dialogs/snackbars/toasts + */ + public static void showUserInfoIfPresent(@NonNull final Context context) { + if (MIGRATION_INFO.isEmpty()) { + return; + } + + try { + MIGRATION_INFO.get(0).accept(context); + } catch (final Exception e) { + ErrorUtil.showUiErrorSnackbar(context, "Showing migration info to the user", e); + // Remove the migration that caused the error and continue with the next one + MIGRATION_INFO.remove(0); + showUserInfoIfPresent(context); + } + } + + /** + * Add a migration info action that will be executed after the UI is initialized. + * This can be used to show dialogs/snackbars/toasts to inform the user about changes that + * were applied by migrations. + * + * @param info the action to be executed + */ + public static void addMigrationInfo(final Consumer info) { + MIGRATION_INFO.add(info); + } + + /** + * This method should be called when the user dismisses the migration info + * to check if there are any more migration info actions to be shown. + * @param context Context that can be used to show dialogs/snackbars/toasts + */ + public static void onMigrationInfoDismissed(@NonNull final Context context) { + MIGRATION_INFO.remove(0); + showUserInfoIfPresent(context); + } + + /** + * Creates a dialog to inform the user about the migration. + * @param uiContext Context that can be used to show dialogs/snackbars/toasts + * @param title the title of the dialog + * @param message the message of the dialog + * @return the dialog that can be shown to the user with a custom dismiss listener + */ + static AlertDialog createMigrationInfoDialog(@NonNull final Context uiContext, + @NonNull final String title, + @NonNull final String message) { + return new AlertDialog.Builder(uiContext) + .setTitle(title) + .setMessage(message) + .setPositiveButton(R.string.ok, null) + .setOnDismissListener(dialog -> + MigrationManager.onMigrationInfoDismissed(uiContext)) + .setCancelable(false) // prevents the dialog from being dismissed accidentally + .create(); + } + +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/SettingMigrations.java b/app/src/main/java/org/schabi/newpipe/settings/migration/SettingMigrations.java similarity index 65% rename from app/src/main/java/org/schabi/newpipe/settings/SettingMigrations.java rename to app/src/main/java/org/schabi/newpipe/settings/migration/SettingMigrations.java index a77e1c514..92520ec7e 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/SettingMigrations.java +++ b/app/src/main/java/org/schabi/newpipe/settings/migration/SettingMigrations.java @@ -1,12 +1,15 @@ -package org.schabi.newpipe.settings; +package org.schabi.newpipe.settings.migration; import static org.schabi.newpipe.MainActivity.DEBUG; +import static org.schabi.newpipe.extractor.ServiceList.SoundCloud; +import static org.schabi.newpipe.extractor.ServiceList.YouTube; import android.content.Context; import android.content.SharedPreferences; import android.util.Log; import androidx.annotation.NonNull; +import androidx.core.util.Consumer; import androidx.preference.PreferenceManager; import org.schabi.newpipe.App; @@ -14,18 +17,32 @@ import org.schabi.newpipe.R; import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.error.UserAction; +import org.schabi.newpipe.settings.tabs.Tab; +import org.schabi.newpipe.settings.tabs.TabsManager; import org.schabi.newpipe.util.DeviceUtils; import java.util.Collections; import java.util.HashSet; +import java.util.List; import java.util.Set; +import java.util.stream.Collectors; /** - * In order to add a migration, follow these steps, given P is the previous version:
- * - in the class body add a new {@code MIGRATION_P_P+1 = new Migration(P, P+1) { ... }} and put in - * the {@code migrate()} method the code that need to be run when migrating from P to P+1
- * - add {@code MIGRATION_P_P+1} at the end of {@link SettingMigrations#SETTING_MIGRATIONS}
- * - increment {@link SettingMigrations#VERSION}'s value by 1 (so it should become P+1) + * This class contains the code to migrate the settings from one version to another. + * Migrations are run automatically when the app is started and the settings version changed. + *
+ * In order to add a migration, follow these steps, given {@code P} is the previous version: + *
    + *
  • in the class body add a new {@code MIGRATION_P_P+1 = new Migration(P, P+1) { ... }} and put + * in the {@code migrate()} method the code that need to be run + * when migrating from {@code P} to {@code P+1}
  • + *
  • add {@code MIGRATION_P_P+1} at the end of {@link SettingMigrations#SETTING_MIGRATIONS}
  • + *
  • increment {@link SettingMigrations#VERSION}'s value by 1 + * (so it becomes {@code P+1})
  • + *
+ * Migrations can register UI actions using {@link MigrationManager#addMigrationInfo(Consumer)} + * that will be performed after the UI is initialized to inform the user about changes + * that were applied by migrations. */ public final class SettingMigrations { @@ -129,7 +146,7 @@ public final class SettingMigrations { } }; - public static final Migration MIGRATION_5_6 = new Migration(5, 6) { + private static final Migration MIGRATION_5_6 = new Migration(5, 6) { @Override protected void migrate(@NonNull final Context context) { final boolean loadImages = sp.getBoolean("download_thumbnail_key", true); @@ -143,6 +160,67 @@ public final class SettingMigrations { } }; + private static final Migration MIGRATION_6_7 = new Migration(6, 7) { + @Override + protected void migrate(@NonNull final Context context) { + // The SoundCloud Top 50 Kiosk was removed in the extractor, + // so we remove the corresponding tab if it exists. + final TabsManager tabsManager = TabsManager.getManager(context); + final List tabs = tabsManager.getTabs(); + final List cleanedTabs = tabs.stream() + .filter(tab -> !(tab instanceof Tab.KioskTab kioskTab + && kioskTab.getKioskServiceId() == SoundCloud.getServiceId() + && kioskTab.getKioskId().equals("Top 50"))) + .collect(Collectors.toUnmodifiableList()); + if (tabs.size() != cleanedTabs.size()) { + tabsManager.saveTabs(cleanedTabs); + // create an AlertDialog to inform the user about the change + MigrationManager.addMigrationInfo(uiContext -> + MigrationManager.createMigrationInfoDialog( + uiContext, + uiContext.getString(R.string.migration_info_6_7_title), + uiContext.getString(R.string.migration_info_6_7_message)) + .show()); + } + } + }; + + private static final Migration MIGRATION_7_8 = new Migration(7, 8) { + @Override + protected void migrate(@NonNull final Context context) { + // YouTube remove the combined Trending kiosk, see + // https://github.com/TeamNewPipe/NewPipe/discussions/12445 for more information. + // If the user has a dedicated YouTube/Trending kiosk tab, + // it is removed and replaced with the new live kiosk tab. + // The default trending kiosk tab is not touched + // because it uses the default kiosk provided by the extractor + // and is thus updated automatically. + final TabsManager tabsManager = TabsManager.getManager(context); + final List tabs = tabsManager.getTabs(); + final List cleanedTabs = tabs.stream() + .filter(tab -> !(tab instanceof Tab.KioskTab kioskTab + && kioskTab.getKioskServiceId() == YouTube.getServiceId() + && kioskTab.getKioskId().equals("Trending"))) + .collect(Collectors.toUnmodifiableList()); + if (tabs.size() != cleanedTabs.size()) { + tabsManager.saveTabs(cleanedTabs); + } + + final boolean hasDefaultTrendingTab = tabs.stream() + .anyMatch(tab -> tab instanceof Tab.DefaultKioskTab); + + if (tabs.size() != cleanedTabs.size() || hasDefaultTrendingTab) { + // User is informed about the change + MigrationManager.addMigrationInfo(uiContext -> + MigrationManager.createMigrationInfoDialog( + uiContext, + uiContext.getString(R.string.migration_info_7_8_title), + uiContext.getString(R.string.migration_info_7_8_message)) + .show()); + } + } + }; + /** * List of all implemented migrations. *

@@ -156,15 +234,17 @@ public final class SettingMigrations { MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, + MIGRATION_6_7, + MIGRATION_7_8, }; /** * Version number for preferences. Must be incremented every time a migration is necessary. */ - private static final int VERSION = 6; + private static final int VERSION = 8; - public static void runMigrationsIfNeeded(@NonNull final Context context) { + static void runMigrationsIfNeeded(@NonNull final Context context) { // setup migrations and check if there is something to do sp = PreferenceManager.getDefaultSharedPreferences(context); final String lastPrefVersionKey = context.getString(R.string.last_used_preferences_version); diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamMenu.kt b/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamMenu.kt index 935bda85f..7619515e7 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamMenu.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamMenu.kt @@ -28,10 +28,9 @@ fun StreamMenu( ) { val context = LocalContext.current val streamViewModel = viewModel() - val playerHolder = PlayerHolder.getInstance() DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) { - if (playerHolder.isPlayQueueReady) { + if (PlayerHolder.isPlayQueueReady) { DropdownMenuItem( text = { Text(text = stringResource(R.string.enqueue_stream)) }, onClick = { @@ -42,7 +41,7 @@ fun StreamMenu( } ) - if (playerHolder.queuePosition < playerHolder.queueSize - 1) { + if (PlayerHolder.queuePosition < PlayerHolder.queueSize - 1) { DropdownMenuItem( text = { Text(text = stringResource(R.string.enqueue_next_stream)) }, onClick = { diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentRepliesHeader.kt b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentRepliesHeader.kt index 3a3fae480..f9c44b80c 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentRepliesHeader.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentRepliesHeader.kt @@ -81,12 +81,11 @@ fun CommentRepliesHeader(comment: CommentsInfoItem, onCommentAuthorOpened: () -> style = MaterialTheme.typography.titleSmall, ) - Text( - text = Localization.relativeTimeOrTextual( - context, comment.uploadDate, comment.textualUploadDate - ), - style = MaterialTheme.typography.bodySmall, - ) + Localization.relativeTimeOrTextual( + context, comment.uploadDate, comment.textualUploadDate + )?.let { + Text(text = it, style = MaterialTheme.typography.bodySmall) + } } } diff --git a/app/src/main/java/org/schabi/newpipe/util/ChannelTabHelper.java b/app/src/main/java/org/schabi/newpipe/util/ChannelTabHelper.java index 8e8d38490..cde6e3fef 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ChannelTabHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ChannelTabHelper.java @@ -24,6 +24,7 @@ public final class ChannelTabHelper { switch (tab) { case ChannelTabs.VIDEOS: case ChannelTabs.TRACKS: + case ChannelTabs.LIKES: case ChannelTabs.SHORTS: case ChannelTabs.LIVESTREAMS: return true; @@ -62,6 +63,8 @@ public final class ChannelTabHelper { return R.string.show_channel_tabs_playlists; case ChannelTabs.ALBUMS: return R.string.show_channel_tabs_albums; + case ChannelTabs.LIKES: + return R.string.show_channel_tabs_likes; default: return -1; } @@ -78,6 +81,8 @@ public final class ChannelTabHelper { return R.string.fetch_channel_tabs_shorts; case ChannelTabs.LIVESTREAMS: return R.string.fetch_channel_tabs_livestreams; + case ChannelTabs.LIKES: + return R.string.fetch_channel_tabs_likes; default: return -1; } @@ -100,6 +105,8 @@ public final class ChannelTabHelper { return R.string.channel_tab_playlists; case ChannelTabs.ALBUMS: return R.string.channel_tab_albums; + case ChannelTabs.LIKES: + return R.string.channel_tab_likes; default: return R.string.unknown_content; } diff --git a/app/src/main/java/org/schabi/newpipe/util/KioskTranslator.java b/app/src/main/java/org/schabi/newpipe/util/KioskTranslator.java index b8c2ff236..5aa332159 100644 --- a/app/src/main/java/org/schabi/newpipe/util/KioskTranslator.java +++ b/app/src/main/java/org/schabi/newpipe/util/KioskTranslator.java @@ -52,6 +52,14 @@ public final class KioskTranslator { return c.getString(R.string.featured); case "Radio": return c.getString(R.string.radio); + case "trending_gaming": + return c.getString(R.string.trending_gaming); + case "trending_music": + return c.getString(R.string.trending_music); + case "trending_movies_and_shows": + return c.getString(R.string.trending_movies); + case "trending_podcasts_episodes": + return c.getString(R.string.trending_podcasts); default: return kioskId; } @@ -77,6 +85,14 @@ public final class KioskTranslator { return R.drawable.ic_stars; case "Radio": return R.drawable.ic_radio; + case "trending_gaming": + return R.drawable.ic_videogame_asset; + case "trending_music": + return R.drawable.ic_music_note; + case "trending_movies_and_shows": + return R.drawable.ic_movie; + case "trending_podcasts_episodes": + return R.drawable.ic_podcasts; default: return 0; } diff --git a/app/src/main/java/org/schabi/newpipe/util/ListHelper.java b/app/src/main/java/org/schabi/newpipe/util/ListHelper.java index 282a88b1e..ea41f3e81 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ListHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ListHelper.java @@ -322,7 +322,7 @@ public final class ListHelper { } // Sort collected streams by name - return collectedStreams.values().stream().sorted(getAudioTrackNameComparator(context)) + return collectedStreams.values().stream().sorted(getAudioTrackNameComparator()) .collect(Collectors.toList()); } @@ -359,7 +359,7 @@ public final class ListHelper { } // Sort tracks alphabetically, sort track streams by quality - final Comparator nameCmp = getAudioTrackNameComparator(context); + final Comparator nameCmp = getAudioTrackNameComparator(); final Comparator formatCmp = getAudioFormatComparator(context); return collectedStreams.values().stream() @@ -867,12 +867,10 @@ public final class ListHelper { * Get a {@link Comparator} to compare {@link AudioStream}s by their languages and track types * for alphabetical sorting. * - * @param context app context for localization * @return Comparator */ - private static Comparator getAudioTrackNameComparator( - @NonNull final Context context) { - final Locale appLoc = Localization.getAppLocale(context); + private static Comparator getAudioTrackNameComparator() { + final Locale appLoc = Localization.getAppLocale(); return Comparator.comparing(AudioStream::getAudioLocale, Comparator.nullsLast( Comparator.comparing(locale -> locale.getDisplayName(appLoc)))) diff --git a/app/src/main/java/org/schabi/newpipe/util/Localization.java b/app/src/main/java/org/schabi/newpipe/util/Localization.java index 825bee343..890981e90 100644 --- a/app/src/main/java/org/schabi/newpipe/util/Localization.java +++ b/app/src/main/java/org/schabi/newpipe/util/Localization.java @@ -5,14 +5,12 @@ import static org.schabi.newpipe.MainActivity.DEBUG; import android.annotation.SuppressLint; import android.content.Context; import android.content.SharedPreferences; -import android.content.res.Configuration; import android.content.res.Resources; import android.icu.text.CompactDecimalFormat; import android.os.Build; +import android.text.BidiFormatter; import android.text.TextUtils; import android.text.format.DateUtils; -import android.text.BidiFormatter; -import android.util.DisplayMetrics; import android.util.Log; import androidx.annotation.NonNull; @@ -43,7 +41,6 @@ import java.time.format.FormatStyle; import java.util.Arrays; import java.util.List; import java.util.Locale; -import java.util.Objects; import java.util.stream.Collectors; @@ -120,39 +117,34 @@ public final class Localization { return getLocaleFromPrefs(context, R.string.content_language_key); } - public static Locale getAppLocale(@NonNull final Context context) { - if (Build.VERSION.SDK_INT >= 33) { - final Locale customLocale = AppCompatDelegate.getApplicationLocales().get(0); - return Objects.requireNonNullElseGet(customLocale, Locale::getDefault); - } - return getLocaleFromPrefs(context, R.string.app_language_key); + public static Locale getAppLocale() { + final Locale customLocale = AppCompatDelegate.getApplicationLocales().get(0); + return customLocale != null ? customLocale : Locale.getDefault(); } - public static String localizeNumber(@NonNull final Context context, final long number) { - return localizeNumber(context, (double) number); + public static String localizeNumber(final long number) { + return localizeNumber((double) number); } - public static String localizeNumber(@NonNull final Context context, final double number) { - final NumberFormat nf = NumberFormat.getInstance(getAppLocale(context)); - return nf.format(number); + public static String localizeNumber(final double number) { + return NumberFormat.getInstance(getAppLocale()).format(number); } - public static String formatDate(@NonNull final Context context, - @NonNull final OffsetDateTime offsetDateTime) { + public static String formatDate(@NonNull final OffsetDateTime offsetDateTime) { return DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) - .withLocale(getAppLocale(context)).format(offsetDateTime - .atZoneSameInstant(ZoneId.systemDefault())); + .withLocale(getAppLocale()) + .format(offsetDateTime.atZoneSameInstant(ZoneId.systemDefault())); } @SuppressLint("StringFormatInvalid") public static String localizeUploadDate(@NonNull final Context context, @NonNull final OffsetDateTime offsetDateTime) { - return context.getString(R.string.upload_date_text, formatDate(context, offsetDateTime)); + return context.getString(R.string.upload_date_text, formatDate(offsetDateTime)); } public static String localizeViewCount(@NonNull final Context context, final long viewCount) { return getQuantity(context, R.plurals.views, R.string.no_views, viewCount, - localizeNumber(context, viewCount)); + localizeNumber(viewCount)); } public static String localizeStreamCount(@NonNull final Context context, @@ -166,7 +158,7 @@ public final class Localization { return context.getResources().getString(R.string.more_than_100_videos); default: return getQuantity(context, R.plurals.videos, R.string.no_videos, streamCount, - localizeNumber(context, streamCount)); + localizeNumber(streamCount)); } } @@ -187,27 +179,33 @@ public final class Localization { public static String localizeWatchingCount(@NonNull final Context context, final long watchingCount) { return getQuantity(context, R.plurals.watching, R.string.no_one_watching, watchingCount, - localizeNumber(context, watchingCount)); + localizeNumber(watchingCount)); } public static String shortCount(@NonNull final Context context, final long count) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - return CompactDecimalFormat.getInstance(getAppLocale(context), + return CompactDecimalFormat.getInstance(getAppLocale(), CompactDecimalFormat.CompactStyle.SHORT).format(count); } final double value = (double) count; if (count >= 1000000000) { - return localizeNumber(context, round(value / 1000000000)) - + context.getString(R.string.short_billion); + final double shortenedValue = value / 1000000000; + final int scale = shortenedValue >= 100 ? 0 : 1; + return context.getString(R.string.short_billion, + localizeNumber(round(shortenedValue, scale))); } else if (count >= 1000000) { - return localizeNumber(context, round(value / 1000000)) - + context.getString(R.string.short_million); + final double shortenedValue = value / 1000000; + final int scale = shortenedValue >= 100 ? 0 : 1; + return context.getString(R.string.short_million, + localizeNumber(round(shortenedValue, scale))); } else if (count >= 1000) { - return localizeNumber(context, round(value / 1000)) - + context.getString(R.string.short_thousand); + final double shortenedValue = value / 1000; + final int scale = shortenedValue >= 100 ? 0 : 1; + return context.getString(R.string.short_thousand, + localizeNumber(round(shortenedValue, scale))); } else { - return localizeNumber(context, value); + return localizeNumber(value); } } @@ -372,8 +370,8 @@ public final class Localization { prettyTime.removeUnit(Decade.class); } - public static PrettyTime resolvePrettyTime(@NonNull final Context context) { - return new PrettyTime(getAppLocale(context)); + public static PrettyTime resolvePrettyTime() { + return new PrettyTime(getAppLocale()); } public static String relativeTime(@NonNull final OffsetDateTime offsetDateTime) { @@ -391,9 +389,10 @@ public final class Localization { * {@code parsed != null} and the relevant setting is enabled, {@code textual} will * be appended to the returned string for debugging purposes. */ + @Nullable public static String relativeTimeOrTextual(@Nullable final Context context, @Nullable final DateWrapper parsed, - final String textual) { + @Nullable final String textual) { if (parsed == null) { return textual; } else if (DEBUG && context != null && PreferenceManager @@ -405,14 +404,6 @@ public final class Localization { } } - public static void assureCorrectAppLanguage(final Context c) { - final Resources res = c.getResources(); - final DisplayMetrics dm = res.getDisplayMetrics(); - final Configuration conf = res.getConfiguration(); - conf.setLocale(getAppLocale(c)); - res.updateConfiguration(conf, dm); - } - private static Locale getLocaleFromPrefs(@NonNull final Context context, @StringRes final int prefKey) { final SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context); @@ -426,8 +417,8 @@ public final class Localization { } } - private static double round(final double value) { - return new BigDecimal(value).setScale(1, RoundingMode.HALF_UP).doubleValue(); + private static double round(final double value, final int scale) { + return new BigDecimal(value).setScale(scale, RoundingMode.HALF_UP).doubleValue(); } private static String getQuantity(@NonNull final Context context, @@ -447,29 +438,32 @@ public final class Localization { return context.getResources().getQuantityString(pluralId, safeCount, formattedCount); } + // Starting with pull request #12093, NewPipe exclusively uses Android's + // public per-app language APIs to read and set the UI language for NewPipe. + // The following code will migrate any existing custom app language in SharedPreferences to + // use the public per-app language APIs instead. + // For reference, see + // https://android-developers.googleblog.com/2022/11/per-app-language-preferences-part-1.html public static void migrateAppLanguageSettingIfNecessary(@NonNull final Context context) { - // Starting with pull request #12093, NewPipe on Android 13+ exclusively uses Android's - // public per-app language APIs to read and set the UI language for NewPipe. - // If running on Android 13+, the following code will migrate any existing custom - // app language in SharedPreferences to use the public per-app language APIs instead. - if (Build.VERSION.SDK_INT >= 33) { - final SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context); - final String appLanguageKey = context.getString(R.string.app_language_key); - final String appLanguageValue = sp.getString(appLanguageKey, null); - if (appLanguageValue != null) { + final SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context); + final String appLanguageKey = context.getString(R.string.app_language_key); + final String appLanguageValue = sp.getString(appLanguageKey, null); + if (appLanguageValue != null) { + // The app language key is used on Android versions < 33 + // for more info, see ContentSettingsFragment + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { sp.edit().remove(appLanguageKey).apply(); - final String appLanguageDefaultValue = - context.getString(R.string.default_localization_key); - if (!appLanguageValue.equals(appLanguageDefaultValue)) { - try { - AppCompatDelegate.setApplicationLocales( - LocaleListCompat.forLanguageTags(appLanguageValue) - ); - } catch (final RuntimeException e) { - Log.e(TAG, "Failed to migrate previous custom app language " - + "setting to public per-app language APIs" - ); - } + } + final String appLanguageDefaultValue = + context.getString(R.string.default_localization_key); + if (!appLanguageValue.equals(appLanguageDefaultValue)) { + try { + AppCompatDelegate.setApplicationLocales( + LocaleListCompat.forLanguageTags(appLanguageValue)); + } catch (final RuntimeException e) { + Log.e(TAG, "Failed to migrate previous custom app language " + + "setting to public per-app language APIs" + ); } } } diff --git a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java index 55a5b1915..e1b48cd89 100644 --- a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java @@ -200,7 +200,7 @@ public final class NavigationHelper { } public static void enqueueOnPlayer(final Context context, final PlayQueue queue) { - PlayerType playerType = PlayerHolder.getInstance().getType(); + PlayerType playerType = PlayerHolder.INSTANCE.getType(); if (playerType == null) { Log.e(TAG, "Enqueueing but no player is open; defaulting to background player"); playerType = PlayerType.AUDIO; @@ -211,7 +211,7 @@ public final class NavigationHelper { /* ENQUEUE NEXT */ public static void enqueueNextOnPlayer(final Context context, final PlayQueue queue) { - PlayerType playerType = PlayerHolder.getInstance().getType(); + PlayerType playerType = PlayerHolder.INSTANCE.getType(); if (playerType == null) { Log.e(TAG, "Enqueueing next but no player is open; defaulting to background player"); playerType = PlayerType.AUDIO; @@ -421,13 +421,13 @@ public final class NavigationHelper { final boolean switchingPlayers) { final boolean autoPlay; - @Nullable final PlayerType playerType = PlayerHolder.getInstance().getType(); + @Nullable final PlayerType playerType = PlayerHolder.INSTANCE.getType(); if (playerType == null) { // no player open autoPlay = PlayerHelper.isAutoplayAllowedByUser(context); } else if (switchingPlayers) { // switching player to main player - autoPlay = PlayerHolder.getInstance().isPlaying(); // keep play/pause state + autoPlay = PlayerHolder.INSTANCE.isPlaying(); // keep play/pause state } else if (playerType == PlayerType.MAIN) { // opening new stream while already playing in main player autoPlay = PlayerHelper.isAutoplayAllowedByUser(context); diff --git a/app/src/main/java/org/schabi/newpipe/util/ZipHelper.java b/app/src/main/java/org/schabi/newpipe/util/ZipHelper.java index b2aebac42..bccfc7f38 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ZipHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ZipHelper.java @@ -6,12 +6,12 @@ import org.schabi.newpipe.streams.io.StoredFileHelper; import java.io.BufferedInputStream; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; import java.util.zip.ZipOutputStream; @@ -37,9 +37,6 @@ import java.util.zip.ZipOutputStream; */ public final class ZipHelper { - - private static final int BUFFER_SIZE = 2048; - @FunctionalInterface public interface InputStreamConsumer { void acceptStream(InputStream inputStream) throws IOException; @@ -55,17 +52,17 @@ public final class ZipHelper { /** - * This function helps to create zip files. Caution this will overwrite the original file. + * This function helps to create zip files. Caution, this will overwrite the original file. * * @param outZip the ZipOutputStream where the data should be stored in * @param nameInZip the path of the file inside the zip - * @param fileOnDisk the path of the file on the disk that should be added to zip + * @param path the path of the file on the disk that should be added to zip */ public static void addFileToZip(final ZipOutputStream outZip, final String nameInZip, - final String fileOnDisk) throws IOException { - try (FileInputStream fi = new FileInputStream(fileOnDisk)) { - addFileToZip(outZip, nameInZip, fi); + final Path path) throws IOException { + try (var inputStream = Files.newInputStream(path)) { + addFileToZip(outZip, nameInZip, inputStream); } } @@ -80,13 +77,13 @@ public final class ZipHelper { final String nameInZip, final OutputStreamConsumer streamConsumer) throws IOException { final byte[] bytes; - try (ByteArrayOutputStream byteOutput = new ByteArrayOutputStream()) { + try (var byteOutput = new ByteArrayOutputStream()) { streamConsumer.acceptStream(byteOutput); bytes = byteOutput.toByteArray(); } - try (ByteArrayInputStream byteInput = new ByteArrayInputStream(bytes)) { - ZipHelper.addFileToZip(outZip, nameInZip, byteInput); + try (var byteInput = new ByteArrayInputStream(bytes)) { + addFileToZip(outZip, nameInZip, byteInput); } } @@ -97,49 +94,26 @@ public final class ZipHelper { * @param nameInZip the path of the file inside the zip * @param inputStream the content to put inside the file */ - public static void addFileToZip(final ZipOutputStream outZip, - final String nameInZip, - final InputStream inputStream) throws IOException { - final byte[] data = new byte[BUFFER_SIZE]; - try (BufferedInputStream bufferedInputStream = - new BufferedInputStream(inputStream, BUFFER_SIZE)) { - final ZipEntry entry = new ZipEntry(nameInZip); - outZip.putNextEntry(entry); - int count; - while ((count = bufferedInputStream.read(data, 0, BUFFER_SIZE)) != -1) { - outZip.write(data, 0, count); - } - } + private static void addFileToZip(final ZipOutputStream outZip, + final String nameInZip, + final InputStream inputStream) throws IOException { + outZip.putNextEntry(new ZipEntry(nameInZip)); + inputStream.transferTo(outZip); } /** - * This will extract data from ZipInputStream. Caution this will overwrite the original file. + * This will extract data from ZipInputStream. Caution, this will overwrite the original file. * * @param zipFile the zip file to extract from * @param nameInZip the path of the file inside the zip - * @param fileOnDisk the path of the file on the disk where the data should be extracted to + * @param path the path of the file on the disk where the data should be extracted to * @return will return true if the file was found within the zip file */ public static boolean extractFileFromZip(final StoredFileHelper zipFile, final String nameInZip, - final String fileOnDisk) throws IOException { - return extractFileFromZip(zipFile, nameInZip, input -> { - // delete old file first - final File oldFile = new File(fileOnDisk); - if (oldFile.exists()) { - if (!oldFile.delete()) { - throw new IOException("Could not delete " + fileOnDisk); - } - } - - final byte[] data = new byte[BUFFER_SIZE]; - try (FileOutputStream outFile = new FileOutputStream(fileOnDisk)) { - int count; - while ((count = input.read(data)) != -1) { - outFile.write(data, 0, count); - } - } - }); + final Path path) throws IOException { + return extractFileFromZip(zipFile, nameInZip, input -> + Files.copy(input, path, StandardCopyOption.REPLACE_EXISTING)); } /** diff --git a/app/src/main/res/drawable/ic_podcasts.xml b/app/src/main/res/drawable/ic_podcasts.xml new file mode 100644 index 000000000..c297e8fd3 --- /dev/null +++ b/app/src/main/res/drawable/ic_podcasts.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/layout/fragment_blank.xml b/app/src/main/res/layout/fragment_blank.xml index 6c2978e95..4d874ebdb 100644 --- a/app/src/main/res/layout/fragment_blank.xml +++ b/app/src/main/res/layout/fragment_blank.xml @@ -4,7 +4,9 @@ android:layout_width="match_parent" android:layout_height="match_parent"> - + + diff --git a/app/src/main/res/values-ar-rLY/strings.xml b/app/src/main/res/values-ar-rLY/strings.xml index bbf5b8bdf..c9cedc392 100644 --- a/app/src/main/res/values-ar-rLY/strings.xml +++ b/app/src/main/res/values-ar-rLY/strings.xml @@ -75,13 +75,11 @@ الملفات المحملة لا يوجد مثل هذا الملف/مصدر المحتوى الأكثر إعجابًا - بليون تعذر تحميل موجز \'%s\'. ؟ التحقق من وجود تحديثات مثيلات خوادم پيرتيوب +100 فيديو - ألف مثيل الخادم موجود بالفعل طلب تأكيد قبل مسح قائمة الانتظار المشتركون @@ -227,7 +225,6 @@ المميزة عرض المحتوى الذي يُحتمل أن يكون غير مناسب للأطفال لأن له حدًا عمريًا (مثل 18+) بدأ التشغيل في الخلفية - ستتغير اللغة بمجرد إعادة تشغيل التطبيق القصيرة قوائم التشغيل تنظيف @@ -649,7 +646,6 @@ تسريع إلى الأمام/-ترجيع وقت البحث تم رفضها من قبل النظام ليس هناك تعليقات - مليون جاري التحقق من وجود تحديثات… المحتوى اسأل عن مكان التنزيل diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index b2c6c4b64..7b0480e73 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -37,7 +37,7 @@ اعرض خيار لتشغيل الفيديو عبر مركز وسائط Kodi عرض خيار التشغيل بواسطة كودي السمة - تم النشر في %1$s + منشورة على %1$s رابط غير مدعوم استخدام مشغل صوت خارجي استخدام مشغل فيديو خارجي @@ -116,9 +116,6 @@ لا شيء هنا سوى الصراصير الصوت إعادة المحاولة - ألف - مليون - بليون ليس هناك مشترِكون %s مشارك @@ -427,7 +424,6 @@ الكشك الافتراضي لا توجد مشاهدة لا أحد يستمع - ستتغير اللغة بمجرد إعادة تشغيل التطبيق %s مشاهدة %s مشاهدة @@ -879,4 +875,14 @@ لا تستخدم الإعدادات الموجودة في عملية التصدير التي يتم استيرادها تنسيقًا عرضة للاختراق تم إهماله منذ NewPipe 0.27.0. تأكد من أن التصدير الذي يتم استيراده من مصدر موثوق به، ويفضل استخدام عمليات التصدير التي تم الحصول عليها من NewPipe 0.27.0 أو الأحدث في المستقبل فقط. سيتم قريبًا إزالة دعم استيراد الإعدادات بهذا التنسيق الضعيف تمامًا، وبعد ذلك لن تتمكن الإصدارات القديمة من NewPipe من استيراد إعدادات التصدير من الإصدارات الجديدة بعد الآن. الثانوي + المشاركة كقائمة تشغيل مؤقتة على YouTube + قوائم التشغيل + صفحة مجموعة القناة + حدد مجموعة المحتوى + لم تنشئ مجموعة محتوى + الإعجابات + البحث %1$s + البحث %1$s (%2$s) + تمت إزالة صفحة أفضل 50 من SoundCloud + أوقفت SoundCloud صفحة أفضل 50 الأصلية. تمت إزالة علامة التبويب المقابلة من صفحتك الرئيسية. diff --git a/app/src/main/res/values-az/strings.xml b/app/src/main/res/values-az/strings.xml index 3055db9e6..460c2f73d 100644 --- a/app/src/main/res/values-az/strings.xml +++ b/app/src/main/res/values-az/strings.xml @@ -297,9 +297,6 @@ Bəyən Bəyənmə Yenidən sıralamaq üçün sürüklə - min - Mln - Mlrd Xidməti dəyiş, hazırda seçilmiş: Abunəçi yoxdur Baxış yoxdur @@ -342,7 +339,6 @@ Etibarlı ZIP faylı yoxdur Xəbərdarlıq: Bütün faylları idxal etmək mümkün olmadı. Tənzimləmələri də idxal etmək istəyirsiniz\? - Tətbiq yenidən başladıldıqdan sonra dil dəyişəcəkdir Ən yaxşı 50 Yeni və populyar Yerli @@ -801,4 +797,9 @@ Məlumat və yaddaş istifadəsini azaltmaq üçün şəkillərin keyfiyyətini və ya şəkillərin əsla yüklənib-yüklənilməməsini seçin. Dəyişikliklər həm yaddaşdaxili, həm də diskdə olan təsvir qalığın təmizləyir — %s URL siyahısını paylaşın ikinci dərəcəli + YouTube müvəqqəti pleylisti kimi paylaş + Pleylistlər + Axın qrupu seçin + Hələ heç bir axın qrupu yaradılmayıb + Kanal qrupu səhifəsi diff --git a/app/src/main/res/values-azb/strings.xml b/app/src/main/res/values-azb/strings.xml new file mode 100644 index 000000000..55344e519 --- /dev/null +++ b/app/src/main/res/values-azb/strings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/values-b+ast/strings.xml b/app/src/main/res/values-b+ast/strings.xml index 454ddd152..81b212f80 100644 --- a/app/src/main/res/values-b+ast/strings.xml +++ b/app/src/main/res/values-b+ast/strings.xml @@ -43,9 +43,6 @@ Tarrezmes Formatu de videu predetermináu Prietu - mil - mill. - mil mill. Precísase esti permisu p\'abrir \nnel mou ventanu Retu de reCAPTCHA @@ -223,7 +220,6 @@ Política de privacidá de NewPipe El ficheru nun pue crease El sirvidor nun unvia datos - La llingua va camudar namás que se reanicie l\'aplicación. Buscar Compartir con Soscribiéstite diff --git a/app/src/main/res/values-b+uz+Latn/strings.xml b/app/src/main/res/values-b+uz+Latn/strings.xml index 780061c73..2be37ea7c 100644 --- a/app/src/main/res/values-b+uz+Latn/strings.xml +++ b/app/src/main/res/values-b+uz+Latn/strings.xml @@ -267,9 +267,6 @@ Obunachilar yo\'q Hozirda tanlangan xizmatni yoqish: - B - M - k Qayta Audio Video @@ -551,7 +548,6 @@ Yangi va qaynoqlari Top 50 Ommabop - Ilova qayta ishga tushirilgandan so\'ng til o\'zgaradi. Fikrlarni yuklab bo‘lmadi Sozlamalarni ham import qilmoqchimisiz\? Bu sizning joriy sozlamangizni bekor qiladi. diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index 19f3c8b26..a0930a9f8 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -55,7 +55,7 @@ Памятаць апошнія памер і пазіцыю ўсплывальнага акна Хуткі пошук пазіцыі Недакладны пошук дазваляе плэеру знаходзіць пазіцыі хутчэй са зніжанай дакладнасцю. Пошук цягам 5, 15 ці 25 секунд пры гэтым немажлівы - Кэш малюнкаў ачышчаны + Кэш відарысаў ачышчаны Ачысціць кэш метаданых Выдаліць усе даныя вэб-старонак у кэшы Кэш метаданых ачышчаны @@ -71,8 +71,8 @@ Працягваць прайграванне пасля перапынкаў (напрыклад, тэлефонных званкоў) Спампаваць «Наступнае» і «Прапанаванае» відэа - Паказваць падказку «Зацісніце, каб дадаць» - Паказваць падказку пры націсканні «У акне» або «У фоне» на старонцы звестак аб відэа + Паказваць падказку «Утрымлівайце, каб дадаць у чаргу» + Паказваць падказку пры націсканні кнопкі «У акне» або «У фоне» на старонцы відэа URL не падтрымліваецца Прадвызначаная краіна кантэнту Прадвызначаная мова кантэнту @@ -92,7 +92,7 @@ Справаздача пра памылку Усе Каналы - Плэйлісты + Плэй-лісты Трэкі Карыстальнікі Адключана @@ -111,8 +111,8 @@ Перайсці ў галоўнае акно Імпартаваць даныя Экспартаваць даныя - Перавызначае вашу бягучую гісторыю, падпіскі, плэйлісты і (неабавязкова) налады - Экспарт гісторыі, падпісак, плэйлістоў і налад + Перавызначае вашу бягучую гісторыю, падпіскі, плэй-лісты і (неабавязкова) налады + Экспарт гісторыі, падпісак, плэй-лістоў і налад Ачысціць гісторыю праглядаў Выдаліць гісторыю прайграных патокаў і пазіцыі прайгравання Выдаліць усю гісторыю праглядаў\? @@ -135,9 +135,9 @@ Відэапатокі не знойдзены Аўдыяпатокі не знойдзены Такой папкі не існуе - Крыніца кантэнту або файла не існуе + Такога файла або крыніцы кантэнту не існуе Файл не існуе або няма дазволу на яго чытанне ці запіс - Імя файла не можа быць пустым + Назва файла не можа быць пустой Адбылася памылка: %1$s Няма трансляцый, даступных для спампоўвання Прабачце, гэта не павінна было адбыцца. @@ -147,21 +147,18 @@ Інфармацыя: Што адбылося: Што:\\nЗапыт:\\nМова кантэнту:\\nКраіна кантэнту:\\nМова праграмы:\\nСэрвіс:\\nЧас GMT:\\nПакет:\\nВерсія:\\nВерсія АС: - Ваш каментарый (на англійскай): + Ваш каментарый (па-англійску): Падрабязнасці: Прайграць відэа, працягласць: Мініяцюра аватара карыстальніка Спадабалася Не спадабалася Няма вынікаў - Нічога няма, акрамя цвыркуноў + Нічога няма, хоць сабак ганяй Перацягніце, каб змяніць парадак Відэа Аўдыя Паспрабаваць зноў - тыс. - млн - млрд Няма падпісчыкаў %s падпісчык @@ -191,7 +188,7 @@ Адхіліць Перайменаваць ОК - Імя файла + Назва файла Патокі Памылка NewPipe спампоўвае @@ -204,7 +201,7 @@ Запыт reCAPTCHA Запытаны ўвод reCAPTCHA Спампоўванне - Дапушчальныя ў назвах файлаў сімвалы + Сімвалы, дапушчальныя ў назвах файлаў Недапушчальныя сімвалы замяняюцца на гэты Сімвал для замены Літары і лічбы @@ -253,7 +250,7 @@ Выдаліць Падрабязнасці Налады аўдыя - Зацісніце, каб дадаць у чаргу + Утрымлівайце, каб дадаць у чаргу Пачаць прайграванне ў фоне Пачаць прайграванне ў акне Адкрыць бакавую панэль @@ -266,17 +263,17 @@ Заўсёды пытаць Атрыманне звестак… Загрузка запытанага кантэнту - Стварыць плэйліст + Стварыць плэй-ліст Перайменаваць - Імя - Дадаць у плэйліст - Зрабіць мініяцюрай плэйліста - Дадаць плэйліст у закладкі + Назва + Дадаць у плэй-ліст + Зрабіць мініяцюрай плэй-ліста + Дадаць плэй-ліст у закладкі Выдаліць закладку - Выдаліць плэйліст\? - Плэйліст створаны - Дададзена ў плэйліст - Мініяцюра плэйліста зменена. + Выдаліць плэй-ліст? + Плэй-ліст створаны + Дададзена ў плэй-ліст + Мініяцюра плэй-ліста зменена. Без субцітраў Падагнаць Запоўніць @@ -327,10 +324,10 @@ Апавяшчэнні пра новыя версіі NewPipe Знешняе сховішча недаступна Спампоўванне на знешнюю SD-карту немагчыма. Скінуць размяшчэнне папкі спампоўвання? - Памылка чытання захаваных укладак. Выкарыстоўваюцца ўкладкі па змаўчанні + Не ўдалося прачытаць захаваныя ўкладкі, таму выкарыстоўваюцца прадвызначаныя Аднавіць прадвызначаныя значэнні Аднавіць прадвызначаныя значэнні? - Колькасць падпісчыкаў недаступная + Колькасць падпісчыкаў недаступна Укладкі, бачныя на галоўнай старонцы Абнаўленні Паказваць апавяшчэнне пры наяўнасці новай версіі @@ -347,9 +344,9 @@ Дадаць у чаргу Дзеянне забаронена сістэмай Памылка спампоўвання - Стварыць унікальнае імя + Стварыць унікальную назву Перазапісаць - Файл з такім імем ужо спампоўваецца + Файл з такой назвай ўжо спампоўваецца Паказаць тэкст памылкі Немагчыма стварыць папку прызначэння Немагчыма стварыць файл @@ -376,19 +373,19 @@ Працягваць прайграванне Аднаўляць апошнюю пазіцыю Пазіцыі ў спісах - Адлюстроўваць індыкатары пазіцый прагляду ў спісах + Паказваць у спісах пазіцыю прайгравання Ачыстка даных Пазіцыі прайгравання выдалены - Файл перамешчаны ці выдалены - Файл з такім імем ужо існуе - Файл з такім імем ужо існуе + Файл перамешчаны або выдалены + Файл з такой назвай ужо існуе + Спампаваны файл з такой назвай ужо існуе немагчыма перазапісаць файл - Файл з такім імем ужо дададзены ў чаргу спампоўвання + Файл з такой назвай ужо ў чарзе спампоўвання Праграма NewPipe была закрыта падчас працы з файлам На прыладзе скончылася вольнае месца Прагрэс страчаны, бо файл быў выдалены Скончыўся час злучэння - Вы хочаце ачысціць гісторыю спампоўвання ці выдаліць спампаваныя файлы? + Ачысціць гісторыю спампоўвання або выдаліць спампаваныя файлы? Абмежаваць чаргу спампоўвання Толькі адно адначасовае спампоўванне Пачаць спампоўванне @@ -397,7 +394,7 @@ Пры кожным спампоўванні вам будзе прапанавана выбраць месца захавання. \nУключыце сістэмны сродак выбару папак (SAF), калі хочаце спампоўваць файлы на знешнюю SD-карту Выкарыстоўваць сістэмны сродак выбару папак (SAF) «Storage Access Framework» дазваляе выконваць спампоўванне на знешнюю SD-карту - Пераключыць службу, выбраную ў дадзены момант: + Пераключэнне сэрвісу, зараз выбраны: Выдаліць усе пазіцыі прайгравання Уключыць «Абмежаваны рэжым» YouTube Падтрымліваюцца толькі URL-адрасы HTTPS @@ -435,15 +432,15 @@ Найбольш папулярнае Лакальнае Нядаўна дададзенае - Плэйлісты яшчэ не дададзены - Выберыце плэйліст + Плэй-лісты яшчэ не дададзены + Выберыце плэй-ліст Прадвызначаны кіёск Так Па завяршэнні націсніце «Гатова» ∞ відэа 100+ відэа - Багрэпарт на GitHub - Скапіруйце адфарматаваны багрэпарт + Паведаміць на GitHub + Скапіяваць адфарматаваную справаздачу Дайце дазвол на адлюстраванне паверх іншых праграм Выдаліць усе пазіцыі прайгравання\? Выдаліць пазіцыі прайгравання @@ -485,7 +482,7 @@ Выберыце любімую начную тэму - %s Дазвол вылучэння тэксту ў апісанні Вы можаце выбраць сваю любімую начную тэму ніжэй - Гэта опцыя даступна толькі тады, калі %s будзе выбранай тэмаю + Параметр даступны, толькі калі выбрана тэма %s Спампоўванне пачалося Апавяшчэнні адключаны Рэжым планшэта @@ -505,7 +502,7 @@ Начная тэма Адкрыць вэб-сайт Цяпер можна вылучаць тэкст у апісанні. Звярніце ўвагу, што ў рэжыме вылучэння старонка можа мільгаць, а спасылкі не націскацца. - Запускаць галоўны прайгравальнік у поўнаэкранным рэжыме + Запускаць асноўны прайгравальнік у поўнаэкранным рэжыме Паказаць дэталі канала Нізкая якасць (менш) Апавяшчэнне пра відэахэшаванне @@ -537,17 +534,16 @@ %d дзён Ачысціць гісторыю спампоўвання - Мова зменіцца пасля перазапуску праграмы Ніхто не слухае Уключыць Апавяшчэнні пра ход відэахэшавання Стварыць паведамленне пра памылку Выберыце падпіскі - Імпарт ці экспарт падпісак з 3-кропкавага меню + Імпартуйце або экспартуйце падпіскі праз меню з трыма кропкамі ⁝ Забарона вылучэння тэксту ў апісанні Хуткі рэжым Калі ў вас узніклі праблемы з выкарыстаннем праграмы, абавязкова азнаёмцеся з адказамі на частыя пытанні! - Адключыць тунэляванне медыя + Адключыць тунэляванне мультымедыя Мініяцюра з перадпраглядам у паласе перамотвання Высокая якасць (больш) Не паказваць @@ -576,12 +572,12 @@ Апавяшчэнне аб памылцы Апавяшчэнні для паведамлення аб памылках Адбылася памылка NewPipe, націсніце, каб адправіць справаздачу - Запускаць відэа ва ўвесь экран, калі адключаны аўтапаварот. Міні-плэер даступны пры выхадзе з поўнаэкраннага рэжыму + Калі аўтапаварот адключаны, відэа адразу запускаецца ў поўнаэкранным рэжыме. Міні-плэер застаецца даступным, трэба толькі выйсці з поўнаэкраннага рэжыму Шукайце серверы, якія вам даспадобы, на %s Паказваць метаданыя Ігнараваць падзеі апаратных медыякнопак Паказваць змесціва, магчыма непрыдатнае для дзяцей, таму што яно мае ўзроставыя абмежаванні (напрыклад, 18+) - Калі ласка, праверце, ці існуе ўжо праблема з абмеркаваннем вашага збою. Пры стварэнні дублікатаў тыкетаў вы забіраеце ў нас час, які мы маглі б патраціць на выпраўленне фактычнай памылкі. + Праверце, ці не існуе заяўкі з абмеркаваннем вашай праблемы. Дублікаты марнуюць наш час і праз гэта адцягваецца вырашэнне сапраўдных задач. Адбылася памылка, глядзіце апавяшчэнне Збой плэера Карысна, напрыклад, калі вы карыстаецеся гарнітурай са зламанымі фізічнымі кнопкамі @@ -593,7 +589,7 @@ Разлік хэша Вырашана Створана аўтаматычна (запампавальнік не знойдзены) - Плэйлісты, якія пазначаны шэрым, ужо ўтрымліваюць гэты элемент. + Плэй-лісты, якія пазначаны шэрым, ужо ўтрымліваюць гэты элемент. %s новая трансляцыя %s новыя трансляцыі @@ -608,15 +604,15 @@ Дублікат дададзены %d раз(ы) LeakCanary недаступны Паказаць уцечкі памяці - Адключыце мультымедыйнае тунэляванне, калі ў вас з\'яўляецца чорны экран або заіканне падчас прайгравання відэа. + Адключыце тунэляванне мультымедыя, калі відэа прайграецца перарывіста або паказваецца чорны экран. Не ўдалося скапіяваць у буфер абмену Папка спампоўвання яшчэ не зададзена, выберыце папку спампоўвання цяпер Частыя пытанні Перайсці на вэб-сайт - Правядзіце пальцам па элементах, каб выдаліць іх + Каб выдаліць элемент, змахніце яго ўбок Прыбраць пастаянную мініяцюру - Паказваць індыкатары выяў - Паказваць каляровыя стужкі Пікаса на выявах, якія пазначаюць іх крыніцу: чырвоная для сеткі, сіняя для дыска і зялёная для памяці + Паказваць на відарысах указальнікі + Паказваць на відарысах каляровыя меткі Picasso, якія абазначаюць яго крыніцу: чырвоная — сетка, сіняя — дыск, зялёная — памяць Апрацоўка стужкі… Пры кожным спампоўванні вам будзе прапанавана выбраць месца захавання Загрузка канала… @@ -642,10 +638,10 @@ %d выбраных %d выбраных - Пустая назва групы - Выдаліць гэту групу? + Назва групы пустая + Выдаліць групу? Новая - Паказаць толькі разгрупаваныя падпіскі + Паказваць толькі не згрупаваныя падпіскі Запланаваныя Паказваць «Збой плэера» Запусціце праверку новых патокаў @@ -667,13 +663,13 @@ Заўсёды абнаўляць Парог абнаўлення стужкі - Немагчыма загрузіць канал для «%s». + Не ўдалося загрузіць канал для «%s». Стужка Час пасля апошняга абнаўлення, перш чым падпіска лічыцца састарэлай — %s Памылка загрузкі канала Уліковы запіс аўтара быў спынены. \nNewPipe не зможа загрузіць гэты канал у будучыні. \nАдпісацца ад канала? Рэжым хуткай загрузкі стужкі не дае дадатковай інфармацыі аб гэтым. - Атрымлівайце са спецыяльнага канала, калі ён даступны + Атрыманне даных са спецыяльнага канала, калі ён ёсць Уключыць хуткі рэжым Катэгорыя Тэгі @@ -686,7 +682,7 @@ Трансляцыі, спампоўванне якіх яшчэ не падтрымліваецца, не паказваюцца Мініяцюра аватара канала Аўтар: %s - Аўтару відэа спадабалася гэта + Спадабалася аўтару відэа Створана %s Адключыць хуткі рэжым Публічная @@ -699,7 +695,7 @@ Гэты кантэнт яшчэ не падтрымліваецца NewPipe. \n \nСпадзяюся, ён будзе падтрымлівацца ў наступных версіях. - Старонка плэйліста + Старонка плэй-ліста Паказваць мініяцюру Выкарыстоўваць мініяцюру як фон для экрана блакіроўкі і апавяшчэнняў Для гэтага дзеяння не знойдзены прыдатны файлавы менеджар. \nУсталюйце файлавы менеджар або паспрабуйце адключыць «%s» у наладах спампоўвання @@ -713,10 +709,10 @@ Унутраная Прагледжаныя цалкам Гэты кантэнт даступны толькі для аплачаных карыстальнікаў, таму NewPipe не можа яго трансляваць або спампоўваць. - Даступны ў некаторых службах, звычайна нашмат хутчэй, але можа вяртаць абмежаваную колькасць элементаў і часта няпоўную інфармацыю (напрыклад, без працягласці, тыпу элемента, без актыўнага стану) + Даступна для некаторых сэрвісаў, звычайна значна хутчэй, але можа перадаваць абмежаваную колькасць элементаў і не ўсю інфармацыю (можа адсутнічаць працягласць, тып элемента, паказчык трансляцыі) Узроставае абмежаванне Для гэтага дзеяння не знойдзены прыдатны файлавы менеджар. \nУсталюйце файлавы менеджар, сумяшчальны з Storage Access Framework - Ніякая праграма на вашай прыладзе не можа адкрыць гэта + На прыладзе няма праграмы, каб адкрыць гэты файл Стандартнае значэнне ExoPlayer Прагледжаныя часткова Лічыце, што загрузка каналаў адбываецца занадта павольна? Калі так, паспрабуйце ўключыць хуткую загрузку (можна змяніць у наладах або націснуўшы кнопку ніжэй). \n \nNewPipe прапануе два спосабы загрузкі каналаў: \n• Атрыманне ўсяго канала падпіскі. Павольны, але інфармацыя поўная). \n• Выкарыстанне спецыяльнай канчатковай кропкі абслугоўвання. Хуткі, але звычайна інфармацыя няпоўная). \n \nРозніца паміж імі ў тым, што ў хуткім звычайна адсутнічае частка інфармацыі, напрыклад, працягласць або тып (немагчыма адрозніць трансляцыі ад звычайных відэа), і ён можа вяртаць менш элементаў. \n \nYouTube з\'яўляецца прыкладам сэрвісу, які прапануе гэты хуткі метад праз RSS-канал. \n \nТакім чынам, выбар залежыць ад таго, чаму вы аддаяце перавагу: хуткасці або дакладнасці інфармацыя. @@ -748,8 +744,8 @@ У гэтым патоку ўжо павінна быць гукавая дарожка Уключыце гэту опцыю, калі ў вас ёсць праблемы з ініцыялізацыяй дэкодэра, якая вяртаецца да дэкодэраў з больш нізкім прыярытэтам, калі ініцыялізацыя асноўных дэкодэраў не ўдаецца. Гэта можа прывесці да нізкай прадукцыйнасці прайгравання, чым пры выкарыстанні асноўных дэкодэраў Кіраванне некаторымі наладамі ExoPlayer. Каб гэтыя змены ўступілі ў сілу, патрабуецца перазапуск прайгравальніка - Гэты абыходны шлях вызваляе і паўторна стварае відэакодэкі, калі адбываецца змяненне паверхні, замест таго, каб зажаваць паверхню непасрэдна для кодэка. Ужо выкарыстоўваецца ExoPlayer на некаторых прыладах з такой праблемай, гэты параметр ужываецца толькі на прыладах з Android 6 і вышэй\n\nУключэнне параметра можа прадухіліць памылкі прайгравання пры пераключэнні бягучага відэаплэера або пераключэнні ў поўнаэкранны рэжым - Якасць выяў + Гэты абыходны шлях вызваляе і паўторна стварае відэакодэкі, калі адбываецца змяненне паверхні, замест таго, каб задаваць паверхню непасрэдна для кодэка. Ужо выкарыстоўваецца ExoPlayer на некаторых прыладах з такой праблемай, гэты параметр ужываецца толькі на прыладах з Android 6 і вышэй\n\nУключэнне параметра можа прадухіліць памылкі прайгравання пры пераключэнні бягучага відэаплэера або пераключэнні ў поўнаэкранны рэжым + Якасць відарысаў Відэа \? Падпісчыкі @@ -768,28 +764,28 @@ Атрыманне ўкладак канала Аватары Наступны паток - Прадвызначана на вашай прыладзе адключана медыятунэляванне, бо гэтая мадэль прылады яго не падтрымлівае. + Прадвызначана на вашай прыладзе адключана тунэляванне мультымедыя, бо вядома, што гэта мадэль яго не падтрымлівае. Аватары падканалаў Адкрыць чаргу прайгравання - Не загружаць выявы + Не загружаць відарысы Высокая якасць - Аб канале - Абагуліць плэйліст + Пра канал + Абагуліць плэй-ліст Пераматаць наперад Альбомы Пераматаць назад Паўтарыць - Атрыманыя ўкладкі пры абнаўленні стужкі. Гэты параметр не прымяняецца, калі канал абнаўляецца ў хуткім рэжыме. + Укладкі, для якіх атрымліваюцца даныя пры абнаўленні стужкі. Гэты параметр не дзейнічае, калі канал абнаўляецца з выкарыстаннем хуткага рэжыму. Сярэдняя якасць Загрузнік аватараў Банеры - Плэйлісты + Плэй-лісты - %1$s: %2$s Перамясціць панэль укладак уніз Няма жывых трансляцый - Выберыце якасць выяў і ці трэба спампоўваць выявы ўвогуле, каб паменшыць выкарыстанне даных і памяці. Змены ачышчаюць кэш выяў як у памяці, так і на дыску - %s + Выберыце якасць відарысаў ці ўвогуле не загружаць відарысы, каб паменшыць выкарыстанне даных і памяці. Змены ачышчаюць кэш відарысаў у памяці і на дыску (%s) Прайграць - Іншыя опцыі + Іншыя параметры Мініяцюры Трэкі Працягласць @@ -808,7 +804,7 @@ Каб адрэдагаваць кожнае з дзеянняў у апавяшчэнні, націсніце на яго. Першыя тры дзеянні (прайграванне/паўза, папярэдні і наступны) зададзены сістэмай, іх змяніць немагчыма. Недастаткова вольнага месца на прыладзе Так - NewPipe можа аўтаматычна правяраць наяўнасць абнаўленняў і паведаміць вам, калі яны будуць даступны. \nУключыць гэту функцыю? + NewPipe можа час ад часу аўтаматычна правяраць наяўнасць новай версіі і апавяшчаць, калі яна будзе даступна. \nУключыць гэту функцыю? Налады ў імпартаваным экспарце выкарыстоўваюць уразлівы фармат, які састарэў з версіі NewPipe 0.27.0. Пераканайцеся, што імпартаваны экспарт атрыманы з надзейнай крыніцы, і ў будучыні пераважней выкарыстоўваць толькі экспарт, атрыманы з NewPipe 0.27.0 ці навей. Падтрымка імпарту налад у гэтым уразлівым фармаце хутка будзе цалкам выдаленая, і тады старыя версіі NewPipe больш не змогуць імпартаваць наладкі з экспарту з новых версій. Не Рэзервовае капіяванне і аднаўленне @@ -816,4 +812,12 @@ Скінуць усе налады на іх прадвызначаныя значэнні Пры скіданні ўсіх налад будуць адхілены ўсе вашы змены налад і праграма перазапусціцца. \n \nСапраўды хочаце працягнуць? другасны + Абагуліць як часовы плэйліст YouTube + Плэй-лісты + Выберыце групу каналаў + Група каналаў яшчэ не створана + Старонка групы каналаў + Пошук %1$s + Пошук %1$s (%2$s) + Спадабалася diff --git a/app/src/main/res/values-ber/strings.xml b/app/src/main/res/values-ber/strings.xml index 3912381fc..39247f49b 100644 --- a/app/src/main/res/values-ber/strings.xml +++ b/app/src/main/res/values-ber/strings.xml @@ -45,9 +45,6 @@ ∞ ⵉⴼⵉⴷⵢⵓⵜⵏ 100+ ⵉⴼⵉⴷⵢⵓⵜⵏ - - - ⴰⵎⵙⵍⴰⵢ ⴰⴼⵉⴷⵢⵓ ⵉⵔⵉⵜⵏ diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index 926498649..a8e9a75f0 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -183,9 +183,6 @@ Името на файла не може да бъде празно Възникна грешка: %1$s Не са налични източници за изтегляне - хил. - млн. - млрд. Няма абонати Създай Откажи @@ -229,7 +226,7 @@ Съдържание на главната страница Празна страница Страница-павилион - Страница на определен канал + Страница на канал Изберете канал За момента нямате абонаменти Изберете павилион @@ -432,7 +429,6 @@ Най-харесвани Готово Коментари - Езикът ще се смени след рестартиране на приложението Скрит Частен Предложения за отдалечено търсене @@ -809,4 +805,14 @@ Винаги използвайте заобикаляне на настройката на повърхността на видеоизхода на ExoPlayer Изтрий позиции за възпроизвеждане вторичен + Споделяне като временен плейлист в YouTube + Плейлисти + Все още няма създадена група за емисии + Страница на групата канали + Изберете група емисии + Търсене %1$s + Търсене %1$s (%2$s) + Харесвания + Страница SoundCloud Top 50 е премахната + SoundCloud преустанови оригиналните класации Топ 50. Съответният раздел е премахнат от главната ви страница. diff --git a/app/src/main/res/values-bn-rBD/strings.xml b/app/src/main/res/values-bn-rBD/strings.xml index ddc32e418..2b0ffe918 100644 --- a/app/src/main/res/values-bn-rBD/strings.xml +++ b/app/src/main/res/values-bn-rBD/strings.xml @@ -82,9 +82,6 @@ ভিডিও অডিও পুনরায় চেষ্টা করো - হা - M - বি শুরু বিরতি diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml index 14201bc9e..23ad8e947 100644 --- a/app/src/main/res/values-bn/strings.xml +++ b/app/src/main/res/values-bn/strings.xml @@ -104,9 +104,6 @@ কোন ভিডিও নেই কোন ভিউ নেই কোন সাবস্ক্রাইবার নেই - B - M - K পুনরায় চেষ্টা করো অডিও ভিডিও @@ -534,7 +531,6 @@ প্রত্যেক ডাউনলোড কোথায় রাখা হবে তা জিজ্ঞেস করা হবে। \nমেমোরি কার্ডে ডাউনলোড করতে সিস্টেম ফোল্ডার পিকার (SAF) এনেবল করুন এই নামের একটি ডাউনলোড চলমান - অ্যাপ আবার শুরু হলে ভাষা পাল্টাবে মিডিয়া সুরঙ্গকরণ অক্ষম দ্রুত ফিড অবস্থা এ বিষয়ে এর বেশি তথ্য দেয় না। কোনো ডাউনলোড ফোল্ডার নির্দিষ্ট করা হয়নি, এখনই একটা সহজাত ডাউনলোড ফোল্ডার নির্বাচন করো diff --git a/app/src/main/res/values-bqi/strings.xml b/app/src/main/res/values-bqi/strings.xml new file mode 100644 index 000000000..a6b3daec9 --- /dev/null +++ b/app/src/main/res/values-bqi/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/app/src/main/res/values-br/strings.xml b/app/src/main/res/values-br/strings.xml new file mode 100644 index 000000000..a0a7744f6 --- /dev/null +++ b/app/src/main/res/values-br/strings.xml @@ -0,0 +1,121 @@ + + + Nullañ + Mat eo + Ket + Digeriñ e-barzh ar merdeer + Digeriñ gant + Rannañ + Pellgargañ + Klask a raec\'h \"%1$s\"? + Rannañ gant + Arverañ ul lenner aodio diavaez + Koumanantiñ + Digoumanantiñ + N\'haller ket kemmañ ar c\'houmanant + N\'haller ket hizivaat ar c\'houmanant + Diskouez an titouroù + Rolloù-lenn enrollet + Rolloù-lenn + Dibab un ivinell + Drekleur + Diflugell + Ouzhpennañ da + Teuliad pellgargañ ar videoioù + Amañ e vez kadavet ar restroù aodio pellgarget + Amañ e vez kadavet ar restroù video pellgarget + O kargañ + Netra + Mentrezh aodio dre ziouer + Dodenn + Dodenn noz + Sklaer + Teñval + Tregern + Roll istor enklask + Pellgargañ + Lañsañ al lenner pennañ e mod skramm a-bezh + Lenn emgefreek + Bro an endalc\'had dre ziouer + Erioloù PeerTube + Tra ebet + Mentrezh video dre ziouer + Lenn mell-divell + Aodio + Lenn gant Kodi + Lintr + Skarzhañ ar roadennoù + Klask + Arverañ ul lenner video diavaez + Teuliad pellgargañ ar restroù aodio + Du + Kinnigoù enklask + Kenderc\'hel al lenn + URL anskor + Yezh an endalc\'had dre ziouer + Pellgargañ restr al lanv + Staliañ + Ya + Koumanantoù + Arventennoù + Setu an disoc\'hoù evit: %s + Lamet e vez an aodio gant diarunustedoù \'zo + Digoumanantet oc\'h bet d\'ar chadenn + Sellet ouzh ar roll istor + Koumanantet + Diarunusted dre ziouer + Diuzit hoc\'h erioloù PeerTube gwell ganeoc\'h + Kavit an erioloù a blij deoc\'h war %s + Ouzhpennañ un eriol + Lenner + Video hag aodio + Roll istor ha krubuilh + Neuz + Diveugañ + Hizivadurioù + Rebuzadur al lenner + Assav ha gwarediñ + War-eeun + Pellgargadurioù + Pep tra + Chadennoù + Videoioù + Loabroù + Arveriaded + Degouezhioù + Tonioù + Albomoù + Arzourien + Diweredekaet + Skarzhañ + Dizober + Dilamet eo bet ar restr + Lenn pep tra + Atav + Restr + Rebuzadurioù + Rebuzadur NewPipe + Rebuzadurioù evit al lenner NewPipe + Rebuzadurioù evit handelvoù nevez NewPipe + Lanvioù nevez + Ur wech nemetken + Diarunusted wellañ + Fazi + Rebuzadur hizivadur an arload + Endalc\'had + Emzalc\'h + Rolloù-lenn + Pellgargadurioù + Sevel un danevell + Munudoù: + Aodio + Klask en-dro + Deskrivadur + Disoc\'h ebet + Endalc’had ebet + Petra zo c\'hoarvezet: + Lenn ar video, pad: + Titouroù: + Video + + diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 1a9463d05..6e266509f 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -54,6 +54,7 @@ Àudio %s subscriptor + %s subscriptors %s subscriptors D\'acord @@ -169,11 +170,13 @@ Cap reproducció %s reproducció + %s reproduccions %s reproduccions Cap vídeo %s vídeo + %s vídeos %s vídeos Pausa @@ -228,9 +231,6 @@ S\'està recuperant el reproductor després de l\'error Bé, és lamentable. Arrossegueu per reordenar la llista - mil - milions - mil milions Inicia Feu un toc aquí per a més detalls Defineix una carpeta de baixades més endavant als paràmetres @@ -274,15 +274,7 @@ La supervisió de fugues de memòria pot fer que l\'aplicació deixi de respondre mentre es bolca la memòria Informa d\'errors fora del cicle de vida Força l\'informe d\'excepcions Rx que no es puguin transmetre que tinguin lloc fora del cicle de vida d\'un fragment o activitat després de disposar-los - Importeu les vostres subscripcions de YouTube mitjançant la còpia de contingut de Google Takeout: -\n -\n1. Aneu a : %1$s -\n2. Inicieu la sessió si se us demana -\n3. Premeu \"Totes les dades incloses\", després \"Dessel·lecciona-ho tot\", llavors sel·leccioneu només \"Subscripcions\" i finalment premeu \"D\'acord\". -\n4. Premeu \"Pas següent\" i llavors a \"Crea una exportació\" -\n5. Premeu el botó \"Baixa\" un cop hagi aparegut -\n6. Premeu a IMPORTA EL FITXER i sel·leccioneu el fitxer .zip descarregat -\n7. [En cas que la importació del fitxer .zip hagi fallat] extreieu-ne el fitxer subscripcions.csv (es troba generalment a \"Takeout/YouTube i YouTube Music/subscripcions/subscripcions.csv\"), premeu a IMPORTA EL FITXER i sel·leccioneu el fitxer .csv extret. + Importeu les vostres subscripcions de YouTube mitjançant la còpia de contingut de Google Takeout: \n \n1. Aneu a : %1$s \n2. Inicieu la sessió si se us demana \n3. Premeu \"Totes les dades incloses\", després \"Dessel·lecciona-ho tot\", llavors sel·leccioneu només \"Subscripcions\" i finalment premeu \"D\'acord\". \n4. Premeu \"Pas següent\" i llavors a \"Crea una exportació\" \n5. Premeu el botó \"Baixa\" un cop hagi aparegut \n6. Premeu a IMPORTA EL FITXER i sel·leccioneu el fitxer .zip descarregat \n7. [En cas que la importació del fitxer .zip hagi fallat] extreieu-ne el fitxer subscripcions.csv (es troba generalment a \"Takeout/YouTube i YouTube Music/subscripcions/subscripcions.csv\"), premeu a IMPORTA EL FITXER i sel·leccioneu el fitxer .csv extret Importeu un perfil del SoundCloud mitjançant l\'URL o l\'identificador del vostre perfil: \n \n1. Activeu el «Mode d\'ordinador» en un navegador (el lloc web no està disponible per a dispositius mòbils) @@ -407,7 +399,7 @@ El fitxer s\'ha mogut o suprimit Només una baixada alhora Fes servir el SAF - El SAF (Storage Access Framework; estructura d\'accés a l\'emmagatzematge) us permet realitzar baixades a una memòria externa com una targeta SD. + El SAF (Storage Access Framework; estructura d\'accés a l\'emmagatzematge) us permet realitzar baixades a una memòria externa com una targeta SD Esborra les posicions de reproducció Esborra totes les posicions de reproducció Voleu suprimir tots els punts de reproducció\? @@ -415,14 +407,15 @@ Cap visualització %s visualització + %s visualitzacions %s visualitzacions Cap reproducció %s escoltant + %s escoltants %s escoltants - Es canviarà l\'idioma en reiniciar l\'aplicació Tendències Ensenya el temps passat original sobre els \"items\" Auto-generat (no es troba cap uploader) @@ -477,17 +470,18 @@ Actualitza sempre Temps que ha de passar perquè una subscripció es consideri obsoleta — %s Llindar d\'actualització del contingut - Feed + Flux Mostra només les subscripcions sense grup Nou Esteu segurs de voler suprimir aquest grup\? Nom de grup buit - %d de sel·leccionat - %d de sel·leccionats + %d de seleccionat + %d de seleccionats + %d de seleccionats - Cap subscripció sel·leccionada - Sel·leccioneu les subscripcions + Cap subscripció seleccionada + Selecciona subscripcions Processant el contingut… Carregant el contingut… No carregat: %d @@ -495,18 +489,22 @@ Grups de canals %d dia + %d dies %d dies %d hora + %d hores %d hores %d minut + %d minuts %d minuts %d segon + %d segons %d segons A causa de les limitacions d\'ExoPlayer, la durada de cerca és de %d segons @@ -643,13 +641,12 @@ Comprovar manualment si hi ha noves versions Baixada finalitzada + %s baixades finalitzades %s baixades finalitzades Vista prèvia de les miniatures de la barra de cerca - No s\'ha trobat cap gestor de fitxers adequat per a aquesta acció. -\nInstal·leu un gestor de fitxers compatible amb l\'entorn d\'accés d\'emmagatzematge. - No s\'ha trobat cap gestor de fitxers adequat per a aquesta acció. -\nInstal·leu un gestor de fitxers o intenteu desactivar «%s» als paràmetres de baixada. + No s\'ha trobat cap gestor de fitxers adequat per a aquesta acció. \nInstal·leu un gestor de fitxers compatible amb l\'entorn d\'accés d\'emmagatzematge + No s\'ha trobat cap gestor de fitxers adequat per a aquesta acció. \nInstal·leu un gestor de fitxers o intenteu desactivar «%s» als paràmetres de baixada S\'ha produït un error, consulteu la notificació Afegit el següent vídeo a la cua NewPipe ha trobat un error, toca per informar @@ -660,6 +657,7 @@ S\'estan comprovant les actualitzacions… S\'ha suprimit %1$s baixada + S\'han suprimit %1$s baixades S\'han suprimit %1$s baixades A partir de l\'Android 10 només s\'admet el \"Sistema d\'Accés a l\'Emmagatzematge\" @@ -689,8 +687,8 @@ Format desconegut Cualitat desconeguda Ordenar - Configura la notificació de reproducció actual. - Canvia la mida de l\'interval de càrrega en continguts progressius (actualment %s). Un valor inferior pot accelerar la càrrega inicial del vídeo. + Configura la notificació de reproducció actual + Canvia la mida de l\'interval de càrrega en continguts progressius (actualment %s). Un valor inferior pot accelerar la càrrega inicial del vídeo Ignora els esdeveniments dels botons de reproducció físics Útil, per exemple, si feu servir uns auriculars amb els botons físicament trencats Trieu un gest per la part esquerra de la pantalla @@ -733,4 +731,95 @@ Duplicat afegit/s %d vegada/es El túnel multimèdia s\'ha desactivat de manera predeterminada al dispositiu perquè se sap que el vostre model de dispositiu no ho permet. Semiton + Estàs fent servir la darrera versió de NewPipe + No hi ha prou espai lliure al dispositiu + Llistes de reproducció + Targeta + Vols suprimir tots els elements duplicats d\'aquesta llista de reproducció? + Llistes de reproducció + Suprimeix els duplicats + Restableix la configuració + NewPipe pot cercar automàticament actualitzacions i fer-t\'ho saber en estar disponibles.\nVols habilitar-ho? + Suprimeixo els duplicats? + Restableix tots els paràmetres als valors per defecte + Restablir tots els paràmetres descartarà els teus paràmetres preferits i reiniciarà l\'aplicació.\n\nN\'estàs segur? + Clica per descarregar%s + doblat + Commuta-ho tot + Pròximament + En directe + Reprodueix + Torna a reproduir + Més opcions + Comparteix la llista dels URLs + - %1$s: %2$s + Avatars de l\'autor + Avatars del sots-canal + Subscriptors + Ja hi hauria d\'haver una pista d\'àudio en aquest flux + El contingut escollit no és suportat per cap reproductor extern + No hi ha cap flux de vídeo disponible per a reproductors externs + Escull la pista d\'àudio per a reproductors externs + Desconegut + %1$s%2$s + original + descriptiu + Vídeos + Quines pestanyes es mostren a les pàgines del canal + Obre la cua de reproducció + Canvia l\'orientació de la pantalla + Vídeo anterior + Avança + Qualitat de la imatge + No carregues les imatges + Qualitat mitjana + Qualitat baixa + Comparteix com a llista de reproducció temporal de Youtube + %1$s\n%2$s + Mostra més + Avatars + Bàners + Durada + Rebobina + Comparteix amb els títols + No es mostren els contiguts que no suporten descàrrega + Mostra els vídeos següents + No hi ha cap flux d\'àudio disponible per a reproductors externs + Mostra/Amaga els vídeos + Aquesta opció només està disponible si%ss\'ha seleccionat per al tema + Vídeo següent + + %sresposta + %srespostes + %srespostes + + Recupera les pestanyes del canal + Valor per defecte d\'ExoPlayer + Miniatures + Quant a + Usa sempre la sortida de vídeo d\'ExoPlayer com a solució de contingència + La configuració exportada que vols importar té un format vulnerable que és obsolet des de NewPipe 0.27.0. Assegura\'t que l\'exportació que vols importar prové d\'una font de confiança i prefereix només les exportacions fetes amb NewPipe 0.27.0 o posterior d\'ara endavant. El suport a la importació de configuracions en aquest format vulnerable aviat serà suprimit completament i aleshores les antigues versions de NewPipe ja no podran importar les exportacions de les configuracions des de les noves versions. + Àlbums + Pestanyes del canal + Qualitat alta + Tria la qualitat de les imatges i si carregar-les totalment o no per reduir l\'ús de les dades i la memòria. Els canvis suprimiran la memòria cau de les imatges a la memòria i al disc — %s + Pestanyes que es recuperaran en actualitzar el contingut. Aquesta opció no s\'aplica si el canal s\'actualitza en mode ràpid. + Vist parcialment + Vist completament + Paràmetres d\'ExoPlayer + Gestiona alguns paràmetres d\'ExoPlayer. Caldrà reinciciar el reproductor per activar-los + Usa la funció de suport de decodificació d\'ExoPlayer + Habilita aquesta opció si tens problemes en iniciar el decodificador. S\'usaran decodificadors alternatius de baixa prioritat si falla el decodificador primari. Això pot provocar una disminució de la qualitat de la reproducció en relació a l\'ús del decodificador primari + Aquesta alternativa allibera i reinstancia els còdecs de vídeo si hi ha un canvi de màscara en lloc de configurar-la directament al còdec. ExoPlayer ja ho aplica en alguns dispositius amb aquest problema. Aquesta configuració només té efecte en Android 6 i posteriors\n\nHabilitar aquest opció pot prevenir errors de reproducció en canviar el reproductor actual o en passar a pantalla completa + Pistes + Curts + Canvia a pantalla completa + \? + Comparteix la llista de reproducció + Mostra menys + secundària + Canals + Encara no s\'ha creat cap grup de continguts + Tria un grup de continguts + Pàgina del grup de canals diff --git a/app/src/main/res/values-ckb/strings.xml b/app/src/main/res/values-ckb/strings.xml index 5d6c1d9d9..f532cce70 100644 --- a/app/src/main/res/values-ckb/strings.xml +++ b/app/src/main/res/values-ckb/strings.xml @@ -25,7 +25,6 @@ ژمارەی بەژداری نادیارە ناتوانرێت لەسەر ئەو فایله‌وه‌ جێگیر بکرێت په‌ڕه‌ هەڵبژێرە - ملیۆن +١٠٠ ڤیدیۆیان لێده‌ر هاوردە @@ -46,7 +45,6 @@ ئەمە لەسەر ڕێکخستنەکانی ئێستات جێگیر دەبێت. پەیامەکانی نیوپایپ نیوپایپ لەلایەن چەند خۆبەخشێکەوە دروستکراوە کە کاته‌كانی خۆیان پێ بەخشیووە تاکو باشترین خزمەتگوزاریت پێشکەش بکەن. هیچ نەبێت بە کڕینی کوپێک قاوە یارمەتی گەشەپێدەرەکانمان بدە بۆ ئەوەی کاتی زیاتر تەرخان بکەین بۆ بەرەوپێشبردنی نیوپایپ. - ملیار گەڕانی پێشنیارکراوەکان خێرا فایل سڕایەوە @@ -107,7 +105,6 @@ ناتوانرێت به‌ژداریكردنه‌كه‌ نوێبكرێته‌وه‌ پشت شاشە بێ ئەنجامه‌ - زمان دەگۆڕدرێت لەدوای داگیرساندنەوەی به‌رنامه‌كه‌ لادانی سەیرکراو پیشاندانی نیشانەکەری شوێنی کارپێکەر لە خشتەکاندا شوێنەکان لە خشتەکاندا @@ -374,7 +371,6 @@ ناتوانرێت داببه‌زێنرێت ناتوانرێت بە ڕاژەكه‌وە پەیوەست ببیت لێدانی ڤیدیۆ، مه‌ودا: - هەزار زۆرترین بەدڵ سڕینەوە جۆری بنەڕەتی ڤیدیۆ diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index bb0a1ec7b..169b6e0ba 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -57,7 +57,7 @@ Jméno souboru Vlákna Zastavit - Smazat + Odstranit Start Zkusit znovu Video @@ -83,9 +83,7 @@ Určete prosím složku pro stahování později v nastavení Co:\\nŽádost:\\nJazyk obsahu:\\nZemě obsahu:\\nJazyk aplikace:\\nSlužba:\\nČas GMT:\\nBalíček:\\nVerze:\\nVerze OS: Vše - tis. Otevřít ve vyskakovacím okně - mil. Toto oprávnění je vyžadováno \npro otevření ve vyskakovacím okně Odstraňuje zvuk v některých rozlišeních @@ -124,7 +122,6 @@ Oznámení pro NewPipe přehrávač Žádné výsledky Je tu sranda jak v márnici - mld. Žádní odběratelé %s odběratel @@ -139,9 +136,9 @@ Žádná videa - %s Video - %s Videa - %s Videí + %s video + %s videa + %s videí Stahování Povolené znaky v názvech souborů @@ -234,8 +231,8 @@ Přidat do playlistu Nastavit jako náhled playlistu Přidat playlist do záložek - Smazat záložku - Smazat tento playlist\? + Odstranit záložku + Odstranit tento playlist? Playlist vytvořen V playlistu Náhled playlistu změněn. @@ -411,9 +408,9 @@ Zobrazit pozici přehrávání v seznamech Pozice playbacku smazány Timeout spojení - Smazat pozice playbacku - Smazat všechny pozice playbacku - Smazat všechny pozice playbacku\? + Vymazat pozice přehrávání + Vymaže všechny pozice přehrávání + Vymazat všechny pozice přehrávání? Přepnout službu, právě vybráno: Nikdo nesleduje @@ -427,7 +424,6 @@ %s posluchači %s posluchačů - Ke změně jazyka dojde po restartu aplikace Výchozí kiosek Délka přetočení vpřed/zpět Instance PeerTube @@ -445,8 +441,8 @@ obnovuji Toto stahování nelze obnovit Vyberte instanci - Smazat historii stahování - Smazat stažené soubory + Vymazat historii stahování + Odstranit stažené soubory Souhlasit se zobrazením přes jiné aplikace Jazyk aplikace Jazyk systému @@ -489,7 +485,7 @@ %d vybráno Prázdné jméno skupiny - Přejete si smazat tuto skupinu\? + Přejete si odstranit tuto skupinu? Nová Novinky Limit aktualizace novinek @@ -688,7 +684,7 @@ Frekvence kontroly Jakákoli síť Požadované síťové připojení - Smazat všechny stažené soubory z disku\? + Odstranit všechny stažené soubory z disku? Objednali jste si nyní tento kanál Všechny přepnout Nové streamy @@ -837,4 +833,14 @@ \nChcete tuto funkci povolit? Nastavení v importovaném exportu používají zranitelný formát. NewPipe používá nový formát od verze 0.27.0. Ujistěte se, že export importujete z důvěryhodného zdroje a v budoucnu upřednostňujte používání exportů získaných z NewPipe 0.27.0 nebo novějších. Podpora importu nastavení v tomto zranitelném formátu bude brzy kompletně odstraněna, kvůli čemuž staré verze NewPipe nebudou moci importovat nastavení z exportů z nových verzí. sekundární + Sdílet jako dočasný playlist YouTube + Playlisty + Vybrat skupinu kanálů + Zatím nebyla vytvořena žádná skupina kanálů + Stránka skupiny kanálů + Hledat %1$s + Hledat %1$s (%2$s) + Líbí se + Stránka SoundCloud Top 50 odstraněna + SoundCloud zrušil původní žebříčky Top 50. Příslušná karta byla odstraněna z vaší hlavní stránky. diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 0312b3c74..fb63a9387 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -305,9 +305,6 @@ Stop Hændelser Ikke andet end fårekyllinger her - t - mio. - mia. %s abonnent %s abonnenter @@ -507,7 +504,6 @@ Stryg på elementer for at fjerne dem Vælg en playliste Ingen playliste-bogmærker endnu - Sproget ændres, når appen genstarter Afspillerkø Vis kanalens detaljer Sæt i kø @@ -823,4 +819,9 @@ Indstillingerne i den eksport, der importeres, bruger et sårbart format, der er blevet forældet siden NewPipe 0.27.0. Sørg for, at den eksport, der importeres, er fra en pålidelig kilde, og brug helst kun eksport fra NewPipe 0.27.0 eller nyere i fremtiden. Understøttelse af import af indstillinger i dette sårbare format fjernes snart helt, og så vil gamle versioner af NewPipe ikke længere være i stand til at importere indstillinger fra eksport fra nye versioner. Sikkerhedskopiering og gendannelse sekundær + Del som midlertidig YouTube-playliste + Playlister + Kanalgruppeside + Vælg en feed-gruppe + Ingen feed-gruppe oprettet endnu diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index e782e700a..26fc13641 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -69,9 +69,6 @@ Fehlerbericht Löschen Prüfsumme - Tsd. - Mio. - Mrd. Dateiname Fehler Bitte warten … @@ -432,7 +429,6 @@ %s Zuhörer %s Zuhörer - Die Sprache ändert sich, sobald die App neu gestartet wird PeerTube-Instanzen Finde auf %s die Instanzen, die dir gefallen Instanz hinzufügen @@ -587,7 +583,7 @@ Keine App auf deinem Gerät kann dies öffnen Dieser Inhalt ist privat, kann also nicht von NewPipe gestreamt oder heruntergeladen werden. Diese Inhalte sind nur für Benutzer verfügbar, die bezahlt haben, können also nicht von NewPipe gestreamt oder heruntergeladen werden. - Dieses Video ist nur für YouTube Music Premium-Mitglieder verfügbar und kann daher nicht von NewPipe gestreamt oder heruntergeladen werden. + Dieses Video ist nur für YouTube-Music-Premium-Mitglieder verfügbar und kann daher nicht von NewPipe gestreamt oder heruntergeladen werden. Dies ist ein SoundCloud Go+ Track, zumindest in deinem Land, kann er von NewPipe nicht gestreamt oder heruntergeladen werden. Dieser Inhalt ist in deinem Land nicht verfügbar. App abstürzen lassen @@ -823,4 +819,14 @@ \nMöchtest du wirklich fortfahren? Die Einstellungen in dem zu importierenden Export verwenden ein angreifbares Format, das seit NewPipe 0.27.0 veraltet ist. Stellen Sie sicher, dass der zu importierende Export aus einer vertrauenswürdigen Quelle stammt, und verwenden Sie in Zukunft nur noch Exporte, die aus NewPipe 0.27.0 oder neuer stammen. Die Unterstützung für den Import von Einstellungen in diesem angreifbaren Format wird bald vollständig entfernt werden, und dann werden alte Versionen von NewPipe nicht mehr in der Lage sein, Einstellungen von Exporten aus neuen Versionen zu importieren. Sekundär + Als temporäre YouTube-Wiedergabeliste teilen + Wiedergabelisten + Eine Feed-Gruppe auswählen + Kanalgruppen-Seite + Es wurde noch keine Feed-Gruppe erstellt + Suche %1$s + Suche %1$s (%2$s) + Gefällt mir + SoundCloud-Top-50-Seite entfernt + SoundCloud hat die ursprünglichen Top-50-Charts abgeschafft. Der entsprechende Tab wurde von deiner Hauptseite entfernt. diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index c0b09ad13..82041e3b4 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -1,7 +1,7 @@ Δημοσιεύθηκε στις %1$s - Δε βρέθηκε πρόγραμμα αναπαραγωγής. Εγκατάσταση του VLC; + Δε βρέθηκε αναπαραγωγός ροής δεδομένων. Εγκατάσταση του VLC; Εγκατάσταση Άκυρο Άνοιγμα σε πρόγραμμα περιήγησης @@ -41,7 +41,6 @@ Μικρογραφία εικόνας προφίλ του χρήστη Like Dislike - δισ/ρια Άνοιγμα σε αναδυόμενο παράθυρο Εγγραφή Εγγεγραμμένος @@ -71,7 +70,7 @@ Ιστορικό Εμφάνιση πληροφοριών Πατήστε το μεγεθυντικό φακό για να ξεκινήσετε. - Δε βρέθηκε πρόγραμμα αναπαραγωγής ροής δεδομένων (μπορείτε να εγκαταστήσετε το VLC για να κάνετε αναπαραγωγή). + Δε βρέθηκε αναπαραγωγός ροής δεδομένων (μπορείτε να εγκαταστήσετε το VLC για να κάνετε αναπαραγωγή). Λήψη του αρχείου ροής Αφαιρείται ο ήχος από κάποιες αναλύσεις Το κανάλι διαγράφηκε @@ -103,7 +102,7 @@ Ανάκτηση αναπαραγωγής Συνέχιση της αναπαραγωγής έπειτα από διακοπές (π.χ. κλήσεις) Εμφάνιση επεξήγησης του «Πιέστε παρατεταμένα για προσθήκη στην ουρά» - Εμφάνιση υπόδειξης όταν πατηθεί το κουμπί παρασκηνίου ή αναδυόμενου παραθύρου στις \"Λεπτομέρειες:\\ στο βίντεο + Εμφάνιση συμβουλής κατά το πάτημα του φόντου ή του αναδυόμενου κουμπιού στο βίντεο «Λεπτομέρειες:» Προεπιλεγμένη χώρα περιεχομένου Αναπαραγωγός Συμπεριφορά @@ -169,8 +168,6 @@ Δεν υπάρχει τίποτα εδώ Σύρετε για ταξινόμηση Προσπάθεια εκ νέου - χιλ. - εκ/ρια Κανένας συνδρομητής %s συνδρομητής @@ -184,7 +181,7 @@ Κανένα βίντεο %s βίντεο - %s βίντεο + %s βίντεο(πολλά) Εκκίνηση Δημιουργία @@ -422,7 +419,6 @@ %s ακροατής %s ακροατές - Η γλώσσα θα αλλάξει αφού επανεκκινηθεί η εφαρμογή Προεπιλεγμένο περίπτερο Μόνο HTTPS σύνδεσμοι υποστηρίζονται Τοπικά @@ -472,7 +468,7 @@ Αυτό το βίντεο έχει περιορισμό ηλικίας. \n \nΕνεργοποιήστε το «%1$s» στις ρυθμίσεις εάν θέλετε να το δείτε. - Ενεργοποίηση \"Περιορισμένη Λειτουργία\\ του YouTube + Ενεργοποίηση \"Περιορισμένη Λειτουργία\" του YouTube Δεν ήταν δυνατή η αναγνώριση της διεύθυνσης URL. Άνοιγμα με άλλη εφαρμογή; Αυτόματη προσθήκη στην ουρά Η ουρά του ενεργού αναπαραγωγού θα αντικατασταθεί @@ -654,7 +650,7 @@ Χειροκίνητος έλεγχος για νέα έκδοση Έλεγχος αναβάθμισης Νέα αντικείμενα τροφοδοσίας - Εμφάνιση «Κατάρρευση αναπαραγωγέα\\ + Εμφάνιση \"Κατάρρευση αναπαραγωγέα\" Εμφανίζει μια επιλογή κατάρρευσης κατά τη χρήση του αναπαραγωγέα Κατάρρευση αναπαραγωγέα Ειδοποίηση αναφοράς σφάλματος @@ -823,4 +819,14 @@ \nΕίστε βέβαιοι ότι θέλετε να συνεχίσετε; Οι ρυθμίσεις στην εξαγωγή που εισάγεται χρησιμοποιούν μια ευάλωτη μορφή που είχε καταργηθεί από το NewPipe 0.27.0. Βεβαιωθείτε ότι η εξαγωγή που εισάγεται προέρχεται από αξιόπιστη πηγή και προτιμήστε να χρησιμοποιείτε μόνο εξαγωγές που λαμβάνονται από το NewPipe 0.27.0 ή νεότερο στο μέλλον. Η υποστήριξη για εισαγωγή ρυθμίσεων σε αυτήν την ευάλωτη μορφή θα καταργηθεί σύντομα εντελώς και, στη συνέχεια, οι παλιές εκδόσεις του NewPipe δεν θα μπορούν πλέον να εισάγουν ρυθμίσεις εξαγωγών από νέες εκδόσεις. δευτερεύων + Λίστες αναπαραγωγής + Μοιραστείτε ως προσωρινή λίστα αναπαραγωγής στο YouTube + Επιλογή ομάδας τροφοδοσίας + Δεν δημιουργήθηκε ομάδα τροφοδοσίας ακόμα + Σελίδα καναλιού ομάδας + Αναζήτηση %1$s + Αναζήτηση %1$s (%2$s) + Likes + Η σελίδα των SoundCloud Top 50 αφαιρέθηκε + Το SoundCloud έχει καταργήσει τα αρχικά charts με τα Top 50. Η αντίστοιχη καρτέλα έχει αφαιρεθεί από την κύρια σελίδα σας. diff --git a/app/src/main/res/values-eo/strings.xml b/app/src/main/res/values-eo/strings.xml index 66e2c4d10..408035e00 100644 --- a/app/src/main/res/values-eo/strings.xml +++ b/app/src/main/res/values-eo/strings.xml @@ -261,9 +261,6 @@ %s spekto %s spektoj - k - M - Mrd Pri NewPipe Eksteraj permesiloj © %1$s de %2$s sub %3$s @@ -421,7 +418,6 @@ %s aŭskultanto %s aŭskultantoj - La lingvo ŝanĝos kiam la apo restartos Daŭro de rapidpluiga/revolva serĉo Instancoj de PeerTube Elekti viajn preferitajn instancojn de PeerTube diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index c1efd2ee7..41a44f3ba 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -47,7 +47,7 @@ Miniatura del avatar del usuario Contenido Mostrar contenido con restricción de edad - Toca la lupa para comenzar.. + Toca la lupa para comenzar. En directo Descargas Descargas @@ -78,12 +78,9 @@ Copiado al portapapeles Defina una carpeta de descargas más tarde en los ajustes La interfaz de la aplicación dejó de funcionar - Qué:\\nSolicitar:\\nIntenga de contenido:\\nPaíse de contenido:\\nIdiomaño de la aplicación:\\nServicio:\\nTiempo de GTT:\\nPaquete:\\nVersión:\\nVersion de SO: + Qué:\\nSolicitud:\\nIdioma del contenido:\\nPaís del contenido:\\nIdioma de la aplicación:\\nServicio:\\nMarca de tiempo:\\nPaquete:\\nVersión:\\nVersión del SO: Negro Todo - k - M - MM Abrir en modo emergente Se necesita este permiso \npara abrir en modo emergente @@ -91,7 +88,7 @@ Reto reCAPTCHA requerido Reproduciendo en modo emergente Formato de vídeo predefinido - Desactivado + Deshabilitado Mostrar resoluciones más altas Solo algunos dispositivos pueden reproducir vídeos en 2K / 4K Resolución predefinida de emergente @@ -130,7 +127,7 @@ Carácter de reemplazo Letras y dígitos La mayoría de los caracteres especiales - Historial de búsquedas + Historial de búsqueda Almacenar búsquedas localmente Historial de vistas Almacenar historial de vídeos vistos @@ -184,7 +181,7 @@ Comenzar a reproducir en segundo plano Reproducir en modo emergente Mostrar la sugerencia \"Mantener presionado para poner a la cola\" - Nuevo y lo mejor + Lo nuevo y lo mejor Mantener pulsado para añadir a la cola Donar NewPipe es desarrollado por voluntarios que emplean su tiempo libre para brindarle la mejor experiencia. Haz una aportación para ayudarlos a crear un NewPipe mejor mientras disfrutan de una taza de café. @@ -277,12 +274,7 @@ \n5. Haga clic en el botón de \"Descargar\" una vez que aparezca \n6. Haga clic en el botón IMPORTAR ARCHIVO que se muestra abajo y seleccione el archivo zip descargado \n7. [En caso de que la importación falle] Extraiga el archivo .csv (generalmente dentro de \"Youtube y Youtube Music/suscripciones/suscripciones.csv\"), haga clic en IMPORTAR ARCHIVO y seleccione el archivo csv extraído anteriormente - Importe un perfil de SoundCloud escribiendo la URL o su ID: -\n -\n1. Active el «modo escritorio» en un navegador web (el sitio no está disponible para dispositivos móviles) -\n2. Vaya a esta URL: %1$s -\n3. Inicie sesión cuando se le pida -\n4. Copie la URL del perfil a la que fue redireccionado. + Importa un perfil de SoundCloud escribiendo la URL o tu ID: \n \n1. Habilita el «modo escritorio» en un navegador web (el sitio no está disponible para dispositivos móviles) \n2. Ve a esta URL: %1$s \n3. Inicia sesión cuando se te pida \n4. Copia la URL del perfil a la que fuiste redireccionado. tuID, soundcloud.com/tuID Esta operación puede causar un uso intensivo de la red. \n @@ -381,8 +373,7 @@ El tiempo de conexión expiro No se puede recuperar esta descarga Preguntar dónde descargar - Se le preguntará dónde guardar cada descarga. -\nHabilite Elegir carpetas del sistema (SAF) si desea guardar las descargas en una tarjeta SD externa + Se te preguntará dónde guardar cada descarga. \nHabilita elegir carpetas del sistema (SAF) si quieres guardar las descargas en una tarjeta SD externa Usar Elegir carpetas del sistema (SAF) El \'Sistema de Acceso al Almacenamiento\' permite descargar en una tarjeta SD externa Desuscribirse @@ -405,7 +396,7 @@ Automático ¡Actualización de NewPipe disponible! Mostrar comentarios - Desactivar para ocultar comentarios + Deshabilitar para ocultar comentarios Reproducción automática Sin comentarios No se pudieron cargar los comentarios @@ -433,7 +424,6 @@ %s oyentes %s oyentes - El idioma cambiará luego de que se reinicie la aplicación Duración de búsqueda al avanzar y/o retroceder Instancias de PeerTube Selecciona tus instancias favoritas de PeerTube @@ -494,14 +484,14 @@ Nombre de grupo vacío ¿Quieres borrar este grupo? Nuevo - Fuente + Contenido Velocidad de actualización del contenido Tiempo para que una suscripción se considere desactualizada — %s Actualizar siempre Extraer desde feed dedicado cuando esté disponible Disponible para algunos servicios, suele ser más rápido pero puede mostrar una cantidad limitada de ítems y a menudo información incompleta (por ejemplo falta de duración, tipo de ítem o estado) - Activar modo rápido - Desactivar modo rápido + Habilitar modo rápido + Deshabilitar modo rápido ¿Piensas que la carga de contenidos es muy lenta\? Entonces intenta habilitar la carga rápida (puedes cambiarlo en los ajustes o pulsando el botón debajo). \n \nNewpipe ofrece dos formas de cargar los contenidos: @@ -521,9 +511,7 @@ Artistas Álbumes Canciones - Este vídeo tiene restricción de edad. -\n -\nActivar \"%1$s\" en los ajustes si quieres verlo. + Este vídeo tiene restricción de edad. \n \nHabilitar \"%1$s\" en los ajustes si quieres verlo. Sí, y también vídeos vistos parcialmente Los vídeos que ya se hayan visto luego de añadidos a la lista de reproducción, serán quitados. \n¿Estás seguro\? ¡Esta acción no se puede deshacer! @@ -534,7 +522,7 @@ Miniatura de avatar del canal Los textos originales de los servicios serán visibles en los ítems de transmisiones Mostrar tiempo atrás original en ítems - Activar el «Modo restringido» de YouTube + Habilitar el «Modo restringido» de YouTube Página de lista de reproducción Mostrar solo suscripciones desagrupadas Aún no hay marcadores para listas de reproducción @@ -577,8 +565,8 @@ Hacer que Android personalice el color de la notificación de acuerdo con el color principal de la miniatura (tenga en cuenta que esto no está disponible en todos los dispositivos) Usar miniatura como fondo de pantalla de bloqueo y notificaciones Mostrar vista previa - Desactivar para ocultar información adicional sobre el creador o contenido de la transmisión - Desactivar para ocultar la descripción del vídeo y la información adicional + Deshabilitar para ocultar información adicional sobre el creador o contenido de la transmisión + Deshabilitar para ocultar la descripción del vídeo y la información adicional Ninguna aplicación en su dispositivo puede abrir esto Capítulos Reciente @@ -614,24 +602,24 @@ No listado Público Soporte - Lenguaje + Idioma Límite de edad Privacidad Licencia Etiquetas Categoría - Inhabilitar la selección de texto de la descripción + Deshabilitar la selección de texto de la descripción Habilitar la selección de texto de la descripción Ahora puede seleccionar el texto dentro de la descripción. Note que la página puede parpadear y los links no serán cliqueables mientras está en el modo de selección. %s da esta razón: - No fue posible cargar el muro por \'%s\'. + No fue posible cargar el feed por \'%s\'. Cuenta cancelada El modo de muro rápido no arroja más información sobre esto. La cuenta del autor ha sido cancelada.\nNewPipe no podrá acceder a ella en el futuro.\n¿Quieres desuscribirte de este canal? Error al cargar el muro Desde Android 10 solo el \'Sistema de Acceso al Almacenamiento\' es soportado Se le preguntará dónde guardar cada descarga - Desactiva la tunelización de los medios si experimentas una pantalla negra durante la reproducción o si la visualización de la imagen es intermitente. + Deshabilita la tunelización de medios si experimentas una pantalla negra durante la reproducción o si la visualización de la imagen es intermitente. Deshabilitar el túnel de medios Aún no se ha seleccionado ninguna carpeta de descargas, elija la carpeta de descargas por defecto ahora Anfitrión @@ -666,7 +654,7 @@ Buscar actualizaciones Buscar nuevas versiones manualmente Buscando actualizaciones… - Nuevos elementos en el muro + Nuevos elementos en el feed Cerrar abruptamente el reproductor Muestra una opción de cierre abrupto al usar el reproductor Mostrar \"Cerrar abruptamente el reproductor\" @@ -676,8 +664,7 @@ Se produjo un error, vea la notificación Crear una notificación de error Mostrar una barra de error - No se ha encontrado un gestor de archivos apropiado para esta acción. -\nPor favor, instale un gestor de archivos o intente desactivar \'%s\' en los ajustes de la descarga + No se ha encontrado un gestor de archivos apropiado para esta acción. \nPor favor, instala un gestor de archivos o intenta deshabilitarlo \'%s\' en los ajustes de la descarga No se encontró ningún administrador de archivos apropiado para esta acción. \n Instale un administrador de archivos compatible con Storage Access Framework Comentario fijado @@ -695,7 +682,7 @@ Notificar de nuevos directos desde las suscripciones Frecuencia de comprobación ¿Desea borrar del disco todos los archivos descargados\? - Las notificaciones están desactivadas + Las notificaciones están deshabilitadas Recibir notificaciones Conmutar todo Cargando detalles del directo… @@ -735,7 +722,7 @@ ¿Eliminar los duplicados\? ¿Quieres eliminar todas las secuencias duplicadas de esta lista de reproducción? Mostrar las siguientes secuencias - Mostrar/Ocultar secuencias + Mostrar/ocultar secuencias Próximamente Eliminar los duplicados Completamente visto @@ -765,7 +752,7 @@ Cambia el tamaño del intervalo de carga en contenidos progresivos (actualmente %s). Un valor más bajo puede acelerar la carga inicial Ajustes de ExoPlayer Gestiona algunos ajustes de ExoPlayer. Estos cambios requieren reiniciar el reproductor para que surtan efecto - Habilite esta opción si tiene problemas con la inicialización del decodificador recurriendo a decodificadores de menor prioridad si el decodificador principal no se inicializa. Esto puede dar como resultado un rendimiento de reproducción más bajo que cuando se usan decodificadores primarios + Habilita esta opción si tiene problemas con la inicialización del decodificador recurriendo a decodificadores de menor prioridad si el decodificador principal no se inicializa. Esto puede dar como resultado un rendimiento de reproducción más bajo que cuando se usan decodificadores primarios Esta solución alternativa libera los códecs de video y los vuelve a instanciar cuando cambia la máscara, en lugar de configurar la máscara directamente en el códec. ExoPlayer ya usa esta configuración en algunos dispositivos con este problema y solo afecta a Android 6 y versiones posteriores \n \nHabilitar esta opción puede evitar errores de reproducción al cambiar el reproductor de video actual o cambiar al modo de pantalla completa @@ -776,7 +763,7 @@ Sin transmisiones en directo Vídeos Suscriptores - Qué pestañas se muestran en las páginas de los canales + Qué pestañas se muestran en las páginas del canal Pestañas del canal Shorts Cargando metadatos… @@ -829,13 +816,16 @@ Respaldar y restaurar Restablecer ajustes Restablecer todos los ajustes a sus valores predeterminados - Restablecer todos los ajustes descartará todos sus ajustes preferidos y reiniciará la aplicación. -\n -\n¿Estas seguro que deseas continuar? + Restablecer todos los ajustes descartará todos sus ajustes preferidos y reiniciará la aplicación. \n \n¿Estás seguro que quieres continuar? No NewPipe puede buscar automáticamente nuevas versiones de vez en cuando y notificarle cuando estén disponibles. \n¿Quieres habilitar esto? La configuración de la exportación que se importa utiliza un formato vulnerable que quedó obsoleto desde NewPipe 0.27.0. Asegúrese de que la exportación que se está importando provenga de una fuente confiable y prefiera usar solo exportaciones obtenidas de NewPipe 0.27.0 o posterior en el futuro. La compatibilidad con la importación de configuraciones en este formato vulnerable pronto se eliminará por completo y, luego, las versiones antiguas de NewPipe ya no podrán importar configuraciones de exportaciones desde las nuevas versiones. secundaria + Compartir como lista de reproducción temporal de YouTube + Lista de reproducción + Selecciona un grupo de feed + Aún no se ha creado ningún grupo de feed + Página de grupo de canales diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index a89d2846b..3bc372c2b 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -146,7 +146,7 @@ Üksikasjad: Esita video, kestus: Üleslaadiaja avatari pisipilt - Meeldib + Meeldimisi Ei meeldi Tulemusi pole Siin pole veel midagi @@ -154,9 +154,6 @@ Video Audio Proovi uuesti - tuh - mln - mld Tellijaid pole %s tellija @@ -206,8 +203,8 @@ Anneta Veebisait Täiendava info ja uudiste lugemiseks külasta NewPipe\'i veebisaiti. - NewPipe\'i privaatsuspoliitika - Loe privaatsuspoliitikat + NewPipe\'i andmekaitsepõhimõtted + Loe andmekaitsepõhimõtteid NewPipe\'i litsents Loe litsentsi Ajalugu @@ -217,7 +214,7 @@ Enim esitatud Avalehe sisu Tühi leht - Kioski leht + Kioskivaade Kanali leht Vali kanal Kanaleid pole veel tellitud @@ -305,7 +302,7 @@ Kui sul on ideid kujunduse muutmisest, koodi puhastamisest või suurtest koodi muudatustest - abi on alati teretulnud. Mida rohkem tehtud, seda paremaks läheb! NewPipe\'i arendajad on vabatahtlikud, kes kulutavad oma vaba aega, toomaks sulle parimat kasutuskogemust. On aeg anda tagasi aidates arendajaid ja muuta NewPipe veel paremaks, nautides ise tassi kohvi. Anneta - NewPipe võtab privaatsust väga tõsiselt. Seetõttu ei kogu rakendus ilma nõusolekuta mingeid andmeid. \nNewPipe\'i privaatsuspoliitika selgitab üksikasjalikult, milliseid andmeid saadetakse ja kogutakse veateate saatmisel. + NewPipe võtab privaatsust väga tõsiselt. Seetõttu ei kogu rakendus ilma nõusolekuta mingeid andmeid. \nNewPipe\'i andmekaitsepõhimõtted selgitavad üksikasjalikult, milliseid andmeid saadetakse ja kogutakse veateate saatmisel. NewPipe on vaba ja avatud lähtekoodiga tarkvara. Seada võid kasutada, uurida, jagada ja parandada nii, nagu õigemaks pead. Täpsemalt - seda võid levitada ja/või muuta vastavalt Vaba Tarkvara Sihtasutuse avaldatud GNU Üldise Avaliku Litsentsi v.3 (või sinu valikul hilisema versiooni) tingimustele. Teavita elutsüklist väljas vigadest Impordi SoundCloudi profiil trükkides URL või oma ID: @@ -320,7 +317,7 @@ Keri helitu koht edasi Samm Lähtesta - Selleks, et täita Euroopa Üldist Andmekaitse Määrust (GDPR), juhime tähelepanu NewPipe\'i privaatsuspoliitikale. Palun loe seda hoolikalt. \nMeile veateate saatmiseks pead sellega nõustuma. + Selleks, et täita Euroopa Üldist Andmekaitse Määrust (GDPR), juhime tähelepanu NewPipe\'i andmekaitsepõhimõtetele. Palun loe seda hoolikalt. \nMeile veateate saatmiseks pead sellega nõustuma. Minimeeri, kui kasutad teisi rakendusi Tegevus lülitusel peamiselt videopleierilt teisele rakendusele — %s Pole @@ -487,7 +484,6 @@ Lisa esitusjärjekorda Hiljuti lisatud Kohalikud - Keele muutus jõustub rakenduse uuesti käivitamisel Kommentaaride laadimine ei õnnestunud Esitusloendi järjehoidjaid veel pole Vali esitusloend @@ -808,4 +804,14 @@ Ei Imporditavad andmed kasutavad turvaprobleemidega vormingut, mida alates versioonist 0.27.0 NewPipe enam kasutada ei suuda. Palun kontrolli, et impordifail on loodud usaldusväärse osapoole poolt ning eelista ekspordifaile, mis on loodud NewPipe\'i versiooniga 0.27.0 või uuemaga. Tugi sellise vana vormingu kasutamisele kaob õige pea ja seejärel NewPipe\'i uuemad ja vanemad versioonid ei saa omavahel andmeid enam vahetada. täiendav + Jaga YouTube\'i ajutise esitusloendina + Esitusloendid + Vali andmevoo grupp + Ühtegi andmevoo gruppi pole veel loodud + Kanalirühmade leht + Otsi: %1$s + Otsi: %1$s (%2$s) + Meeldimisi + SoundCloudi „Top 50“ leht on eemaldatud + SoundCloud on lõpetanud oma algse „Top 50“ edetabeli pidamise. Seega on ka vastav vahekaart meie rakenduse põhivaatest eemaldatud. diff --git a/app/src/main/res/values-eu/strings.xml b/app/src/main/res/values-eu/strings.xml index 415d2a8d3..24b24599e 100644 --- a/app/src/main/res/values-eu/strings.xml +++ b/app/src/main/res/values-eu/strings.xml @@ -84,9 +84,6 @@ Bideoa Audioa Saiatu berriro - k - M - MM Hasi Pausatu Ezabatu @@ -422,7 +419,6 @@ Erreprodukziorako kokapen guztiak ezabatzen ditu Ezabatu erreprodukziorako kokapen guztiak\? Aktibatu zerbitzua, orain hautatua: - Hizkuntza aldatuko da aplikazioa berrabiarazterakoan Kiosko Lehenetsia Aurreratze/atzeratze bilaketaren iraupena PeerTube instantziak diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 6a252e22f..2b5165ded 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -148,9 +148,6 @@ خطایی رخ داد: %1$s جریانی برای بارگیری در دسترس نیست بدون نتیجه - K - M - B %s مشترک %s مشترک @@ -469,7 +466,6 @@ مورد پسندترین‌ها اخیرا اضافه شده محلی - با آغاز دوبارهٔ کاره، زبان تغییر خواهد کرد کیوسک پیش‌فرض انجام شد وقتی انجام شد، «Done» یا «انجام شد» را بفشارید @@ -764,4 +760,59 @@ ؟ پشتیبان‌گیری و بازیابی بدون جریان زنده + کیفیت تصویر + کیفیت متوسّط + کیفیت زیاد + ترجیح صدای اصلی + هم‌رسانی سیاههٔ پخش + کیفیت کم + سیاهه‌های پخش + بازنشانی تنظیمات + ثانویه + نمایش کم‌تر + جابه‌جایی گزینشگر زبانهٔ اصلی به پایین + تونل زدن رسانه به صورت پیش‌گزیده روی افزاره‌تان از کار افتاده چرا که از آن پشتیبانی نمی‌کند. + کنش ژست راست + قطعه‌ای صوتی باید از پیش در این جریان موجود باشد + برای نمونه اگر از گوشی‌ای با دکمه‌های خراب استفاده می‌کنید مفید است + بازنشانی همهٔ تنظیمات همهٔ تنظیمات ترجیحیتان را دور اندعخته و کاره را دوباره آغاز می‌کند.\n\nمطمئنید که می‌خواهید ادامه دهید؟ + استفاده از ویژگی پشتیبان کدگشای اگزوپلیر + زبانه‌های نشان داده شده روی صفحه‌های کانال + تغییر جهت صفحه + بار نکردن تصویرها + هم‌رسانی سیاههٔ نشانی + هم‌رسانی به شکل سیاههٔ پخش موقّتی یوتوب + - %1$s: %2$s + %1$s\n%2$s + استفادهٔ همیشگی از دور زدن تنظیمات سطح خروجی ویدیوی اگزوپلیر + موقعیت زبانه‌های اصلی + واکشی زبانه‌های کانال + گزینش قطعهٔ صوتی برای پخش کننده‌های خارجی + + %s پاسخ + %s پاسخ + + هم‌رسانی با عنوان‌ها + گزینش قطعهٔ صوتی با شرح برای افزار کم‌بینا در صورت وجود + ترجیح صدای شرح دهنده + گزینش کنش ژست نیمهٔ راست صفحه + تعداد %d بار تکرار شده + چهرک‌های بارکننده + چهرک‌های زیرکانال + کنش ژست چپ + گزینش کیفیت تصویرها و این که اصلاً بار شوند یا نه، برای کاهش استفادهٔ حافظه و داده. تغییرات انبارهٔ تصویر حافظه و دیسک را پاک می‌کند — %s + اگر مشکل شروع رمزگشایی دارید ، این گزینه را فعال کنید ، که اگر رمزگشایی اولیه شکست بخورد ، به رمزگشایی های با اولویت پایین تر باز می گردد. این ممکن است منجر به عملکرد پخش ضعیف نسبت به هنگام استفاده از رمزگشایان اولیه شود + این روش دور زدن مشکل به جای تنظیم مستقیم سطح روی رمزینه، آن‌ها را هنگام تغییر سطح آزاد کرده و دوباره راه‌اندازی می‌کند. این تنظیم که از پیش روی برخی افزاره‌ها به دست اگزوپلیر استفاده می‌شد فقط روی اندروید ۶ و بالاتر تأثیر دارد\n\nبه کار انداختن این گزینه می‌تواند از خطاهای پخش هنگام تغییر پخش‌کنندهٔ ویدیوی کنونی یا تغییر به حالت تمام‌صفحه جلوگیری کند + مدیریت برخی تنظیمات اگزوپلیر. اعمال این تغییرات نیازمند آغاز دوبارهٔ پخش‌کننده است + سیاهه‌های پخشی که خاکستری شده‌اند این مورد را از پیش دارند. + ویرایش هر کنش آگاهی زیر با زدن رویش. سه کنش نخست (پخش/مکث، پیشین و بعدی) به دست سامانه تنظیم شده و قابل سفارشی سازی نیستند. + گزینش کنش ژست نیمهٔ چپ صفحه + گزینش قطعهٔ صوتی اصلی فارغ از زبان + نیوپایپ می‌تواند گه‌گاه به صورت خودکار نگارش‌های جدید را بررسی کرده و از وجودشان آگاهتان کند.\nمی‌خواهید به کارش بیندازید؟ + بازنشانی همهٔ تنظیمات به مقدارهای پیش‌گزیده‌شان + نمایش بیش‌تر + تنظیمات داخل برون‌ریزی‌ از قالبی آسیب‌پذیر استفاده می‌کند که از نگارش ۰٫۲۷٫۰ منسوخ شده. مطمئن شوید برون‌ریزی از منبعی مطمئن آمده و ترجیحاً فقط از برون‌ریزی‌های آمده از نگارش ۰٫۲۷٫۰ به بعد استفاده کنید. پشتیبانی از درون‌ریزی تنظیمات به این قالب آسیب‌پذیر به زودی کاملاً‌برداشته خواهد شد و دیگر نگارش‌خای قدیمی‌تر قادر به درون ریزی تنظیمات از نگارش‌های جدید نخواهند بود. + گشودن صف پخش + می‌خواهید همهٔ جریان‌های تکراری را در این سیاههٔ پخش بردارید؟ + زبانه‌هایی که هنگام به‌روز رسانی خوراک واکشی می‌شوند. این گزینه تأثیری روی کانال‌هایی که با ساتفاده از حالت سریع به‌روز می‌شوند ندارد. diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 300493b52..5050cb886 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -102,9 +102,6 @@ Video Ääni Toista uudelleen - t. - milj. - bilj. Ei tilaajia %s tilaaja @@ -337,7 +334,6 @@ Hiljattain lisätyt Paikalliset Pidetyimmät - Kieli vaihtuu, kun sovellus uudelleenkäynnistetään Kommentteja ei voitu ladata Mitkä välilehdet näytetään pääsivulla Valmis @@ -807,4 +803,9 @@ NewPipe voi automaattisesti tarkistaa päivitysten saatavuuden silloin tällöin ja ilmoittaa kun niitä on saatavilla. \nHaluatko ottaa tämän käyttöön? Laitteella ei ole riittävästi vapaata tilaa + Jaa tilapäisenä YouTube-soittolistana + Raidat + \? + toissijainen + Soittolistat diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 48710619b..fe71d35c0 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -5,7 +5,7 @@ Vouliez-vous dire « %1$s » \? Télécharger Dossier de téléchargement vidéo - Choisissez le dossier de téléchargement des vidéos + Choisissez le dossier de téléchargement pour les fichiers vidéos Les vidéos téléchargées sont stockées ici Installer Installer l’application Kore manquante \? @@ -87,8 +87,6 @@ Lecture en mode flottant Désactivés Quoi :\\nRequest :\\nContent Language :\\nContent Country :\\nApp Language :\\nService :\\nGMT Time :\\nPackage :\\nVersion :\\nOS version : - k - M Cette autorisation est nécessaire pour \nutiliser le mode flottant Arrière-plan @@ -100,7 +98,6 @@ Mémoriser les propriétés de la fenêtre flottante Mémoriser les dernières taille et position de la fenêtre flottante Effacer - G Le son peut être absent à certaines définitions Suggestions de recherche Sélectionner les suggestions à afficher lors d’une recherche @@ -164,7 +161,7 @@ Caractères spéciaux Voulez-vous supprimer cet élément de l’historique de recherche \? Contenu de la page principale - Page vide + Page blanche Chaîne Sélectionner une chaîne Tendances @@ -428,7 +425,6 @@ %s auditeurs %s auditeurs - La langue changera une fois que l’application aura redémarré Durée de l’avance et retour rapide Instances PeerTube Veuillez choisir vos instances PeerTube préférées @@ -839,4 +835,14 @@ Pas assez d\'espace disponible sur l\'appareil Les paramètres de l\'export en cours d\'importation utilisent un format vulnérable qui a été déprécié depuis NewPipe 0.27.0. Assurez-vous que l\'export en cours d\'importation provient d\'une source fiable. Privilégiez les exports obtenues à partir de NewPipe 0.27.0 ou des versions plus récentes à l\'avenir. Le support pour l\'importation des paramètres dans ce format vulnérable sera bientôt complètement supprimé et les anciennes versions de NewPipe ne pourront plus importer les paramètres des exports des nouvelles versions. secondaire + Partager comme liste de lecture YouTube temporaire + Listes de lecture + Sélectionnez un groupe de flux + Aucun groupe de flux n\'a encore été créé + Page du groupe de chaînes + Rechercher %1$s + Rechercher %1$s (%2$s) + Likes + Page SoundCloud Top 50 supprimée + SoundCloud a abandonné le classement original du Top 50. L\'onglet correspondant a été supprimé de votre page d\'accueil. diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index 586e351e2..3371bb211 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -158,9 +158,6 @@ Vídeo Audio Tentar de novo - k - M - B Ningún subscrito %s subscrito @@ -463,7 +460,6 @@ Conferencias O que ten mais gústames Engadiuse recentemente - O idioma cambiará unha vez que se reinicie o aplicativo Non se puideron cargar os comentarios Aínda non hai marcadores nesta lista de reprodución Seleccionar unha lista de reprodución diff --git a/app/src/main/res/values-he/strings.xml b/app/src/main/res/values-he/strings.xml index b4c16a473..a8bc152f3 100644 --- a/app/src/main/res/values-he/strings.xml +++ b/app/src/main/res/values-he/strings.xml @@ -105,16 +105,13 @@ פרטים: נגינת סרטון, משך: תמונה ייצוגית של המפרסם - אהבו + לייקים לא אהבו אין תוצאות אין כאן כלום מלבד צרצרים סרטון שמע ניסיון חוזר - אלפ. - מיל. - מיליארד אין מנויים מנוי אחד @@ -179,9 +176,9 @@ היסטוריה למחוק את הפריט הזה מהיסטוריית החיפושים\? תוכן הדף הראשי - דף ריק - דף גישה מזדמנת - דף ערוצים + עמוד ריק + עמוד גישה מזדמנת + עמוד הערוץ נא לבחור ערוץ אין עדיין מינויים לערוצים נא לבחור סוג גישה מזדמנת @@ -196,7 +193,7 @@ להתחיל לנגן בנגן צף הורדת קובץ הזרמה הצגת מידע - רשימות נגינה מסומנות + רשימות השמעה מסומנות הוספה אל מדינת תוכן כברירת מחדל ניפוי שגיאות @@ -432,7 +429,6 @@ %s מאזינים %s מאזינים - השפה תוחלף עם הפעלת היישומון מחדש קיוסק ברירת מחדל משך קפיצה מהירה קדימה/אחורה מופעים של PeerTube @@ -850,4 +846,12 @@ אין מספיק מקום פנוי במכשיר ההגדרות בייצוא המיובא משתמשות בתסדיר פגיע שהוצא משימוש מאז NewPipe 0.27.0. יש לוודא שהייצוא המיובא הוא ממקור מהימן, ועדיף להשתמש רק בייצוא שהושג מ־NewPipe 0.27.0 ומעלה בעתיד. תמיכה בייבוא הגדרות בתסדיר פגיע זה תוסר בקרוב לחלוטין, ואז גרסאות ישנות של NewPipe לא יוכלו לייבא עוד הגדרות של ייצוא מגרסאות חדשות. משני + רשימות נגינה + שיתוף כרשימת נגינה זמנית של YouTube + הגדרת קבוצת ערוצי עדכונים + לא נוצרו עדיין קבוצות ערוצי עדכונים + עמוד קבוצת ערוצים + חיפוש ב־%1$s + חיפוש ב־%1$s‏ (%2$s) + לייקים diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 890ef1347..73d070398 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -126,9 +126,6 @@ वीडियो ऑडियो फिर से कोशिश करें - हज़ार - मिलियन - अरब कोई सब्सक्राइबर नहीं %s सब्सक्राइबर @@ -423,7 +420,6 @@ %s श्रोता %s श्रोता - ऐप के पुनः आरंभ होने के बाद भाषा बदल जाएगी तेज मोड सक्षम करें तेज मोड अक्षम करें क्या आपको लगता है कि फीड लोडिंग बहुत धीमी है\? यदि ऐसा है, तो तेज़ लोडिंग को सक्षम करने का प्रयास करें (आप इसे सेटिंग्स में या नीचे दिए गए बटन को दबाकर बदल सकते हैं)। @@ -823,4 +819,12 @@ बैकअप और रिस्टोर आयात किए जा रहे निर्यात में सेटिंग्स एक कमजोर प्रारूप का उपयोग करती हैं जिसे न्यूपाइप 0.27.0 के बाद से हटा दिया गया था। सुनिश्चित करें कि आयात किया जा रहा निर्यात किसी विश्वसनीय स्रोत से है, और भविष्य में केवल न्यूपाइप 0.27.0 या नए से प्राप्त निर्यात का उपयोग करना पसंद करें। इस असुरक्षित प्रारूप में सेटिंग्स आयात करने के लिए समर्थन जल्द ही पूरी तरह से हटा दिया जाएगा, और फिर न्यूपाइप के पुराने संस्करण अब नए संस्करणों से निर्यात की सेटिंग्स आयात नहीं कर पाएंगे। सेकेंडरी + %1$s खोजें + %1$s (%2$s) खोजें + प्लेलिस्ट + कृपया एक फ़ीड समूह चुनें + अभी तक कोई फ़ीड समूह नहीं बनाया गया है + चैनल समूह पेज + पसंद + यूट्यूब अस्थायी प्लेलिस्ट के रूप में साझा करें diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index 960106e0d..4361e2fa9 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -97,9 +97,6 @@ Video Audio Pokušaj ponovo - tis. - mil - mlrd. Počni Pauziraj Izbriši @@ -406,7 +403,6 @@ Izbrisati sve pozicije reprodukcije\? Nitko ne gleda Nitko ne sluša - Jezik će se promijeniti nakon ponovnog pokretanja aplikcije Standardni kiosk Podržani su samo HTTP URL-ovi Lokalni diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index fb04aa724..0b84cce90 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -49,7 +49,7 @@ Jelentés Információ: Ez történt: - Az Ön megjegyzése (angolul): + Saját hozzászólás (angolul): Részletek: Elnézést, valami balul sült el. Elnézést, ennek nem kellett volna megtörténnie. @@ -127,18 +127,18 @@ Az alkalmazás/kezelőfelület összeomlott Nem sikerült a videó lejátszása A külső lejátszó nem támogatja az ilyen típusú hivatkozásokat - Nem található videó adatfolyam - Nem található hang adatfolyam + Nem található videófolyam + Nem található hangfolyam Mi:\\nKérés:\\nTartalom nyelve:\\nTartalom származási országa:\\nAlkalmazás nyelve:\\nSzolgáltatás:\\nGMT idő:\\nCsomag:\\nVerzió:\\nOperációs rendszer verzió: Nincs találat Közvetítési fájl letöltése Hozzáadás ehhez Gyorsabb, de pontatlan tekerés használata A pontatlan tekerés lehetővé teszi, hogy gyorsabban ugorjon a pozíciókra, de kisebb pontossággal. Az 5, 15, vagy 25 másodperces tekerés nem működik ebben a módban - A bélyegkép gyorsítótára törölve + Bélyegkép gyorsítótára törölve Gyorsítótárazott metaadatok törlése - Minden gyorsítótárazott weboldaladat törlése - A metaadatok gyorsítótára törölve lett + Minden gyorsítótárazott weboldaladat törölve + Metaadatok gyorsítótára törölve Következő videó automatikus sorba állítása Keresési előzmények helyi tárolása Csatornák @@ -166,9 +166,6 @@ Nincs letölthető adatfolyam Nincs itt semmi pár tücskön kívül Húzza az átrendezéshez - e - m - M Nincs feliratkozó %s feliratkozó @@ -202,13 +199,13 @@ Licencek Szabad, egyszerű közvetítésnézés Androidon. Közreműködés - Legyen ötleted a fordítással, a dizájnnal, a forráskód tisztításával vagy egy komolyabb átszervezésével kapcsolatban, bármilyen segítséget szívesen fogadunk. Minél több minden készül el, annál jobb lesz! + Akár fordítással, tervezési változtatásokkal, kódtisztítással, vagy valódi nehéz kódváltoztatással kapcsolatos ötletei vannak, bármilyen segítséget szívesen fogadunk. Minél több minden készül el, annál jobb lesz! Megtekintés a GitHubon Adományozás A NewPipe alkalmazást önkéntesek fejlesztik a szabadidejükben, hogy a lehető legjobb felhasználói élményt nyújtsák. Járuljon hozzá, hogy a fejlesztők még jobbá tegyék alkalmazást, miközben egy csésze kávét szürcsölnek. Hozzájárulás - Honlap - Látogassa meg a NewPipe honlapját további információkért és hírekért. + Weboldal + Látogasson el a NewPipe weboldalára további információkért és hírekért. A NewPipe adatvédelmi irányelvei A NewPipe projekt komolyan veszi az adatvédelmét. Az alkalmazás nem gyűjt semmilyen adatot a beleegyezése nélkül. \nA NewPipe adatvédelmi irányelve részletesen elmagyarázza, mely adatok kerülnek elküldésre és tárolásra az alkalmazás összeomlásának jelentésekor. @@ -270,23 +267,10 @@ Exportálás… Fájl importálása Előző exportálás - A feliratkozások importálása nem sikerült - A feliratkozások exportálása nem sikerült - YouTube feliratkozások importálása a Google Takeoutból: -\n -\n1. Navigáljon erre az oldalra: %1$s -\n2. Jelentkezzen be, ha kérik -\n3. Kattintson „Az összes adatot tartalmazza” gombra, majd a „Kijelölések megszüntetése” gombra, majd válassza ki a „feliratkozások” lehetőséget és kattintson az „OK” gombra -\n4. Kattintson a „Következő lépés”, majd az \"Exportálás indítása” gombra -\n5. Kattintson a „Letöltés” gombra, amikor megjelenik, -\n6. Kattintson a lenti FÁJL IMPORTÁLÁSA gombra, és válassza ki a letöltött ZIP-fájlt -\n7. [Ha a ZIP-fájl importálása nem sikerül] Bontsa ki a .csv fájlt (általában: „YouTube és YouTube Music/feliratkozások/feliratkozások.csv\"), majd kattintson lent a FÁJL IMPORTÁLÁSA gombra, és válassza az exportált CSV-fájlt - SoundCloud-profil importálása a webcím vagy az azonosítójának begépelésével: -\n -\n1. A webböngészőben engedélyezze az „asztali módot” (az oldal nem érhető el mobileszközökön) -\n2. Navigáljon erre a webcímre: %1$s -\n3. Jelentkezzen be, ha kéri -\n4. Másolja ki a profil webcímét, ahova át lett irányítva. + A feliratkozások importálása sikertelen + A feliratkozások exportálása sikertelen + YouTube feliratkozások importálása a Google Takeoutból: \n \n1. Navigáljon erre az oldalra: %1$s \n2. Jelentkezzen be, ha kérik \n3. Kattintson „Az összes adatot tartalmazza” gombra, majd a „Kijelölések megszüntetése” gombra, majd válassza ki a „feliratkozások” lehetőséget és kattintson az „OK” gombra \n4. Kattintson a „Következő lépés”, majd az \"Exportálás indítása” gombra \n5. Kattintson a „Letöltés” gombra, amikor megjelenik, \n6. Kattintson a lenti FÁJL IMPORTÁLÁSA gombra, és válassza ki a letöltött ZIP-fájlt \n7. [Ha a ZIP-fájl importálása nem sikerül] Bontsa ki a .csv fájlt (általában: „YouTube és YouTube Music/feliratkozások/feliratkozások.csv”), majd kattintson lent a FÁJL IMPORTÁLÁSA gombra, és válassza az exportált CSV-fájlt + SoundCloud-profil importálása a webcím vagy az azonosítójának begépelésével: \n \n1. A webböngészőben engedélyezze az „asztali módot” (az oldal nem érhető el mobileszközökön) \n2. Navigáljon a következő webcímre: %1$s \n3. Jelentkezzen be, ha kéri \n4. Másolja ki a profil webcímét, ahova át lett irányítva. saját azonosítója, soundcloud.com/azonosító Ez a művelet adatforgalom-igényes lehet. \n @@ -305,11 +289,11 @@ Lejátszás folytatása a háttérben Lejátszás folytatása felugró ablakban Lejátszás folytatása - A „Tartsa lenyomva a sorba állításhoz\" tipp megjelenítése + A „Tartsa lenyomva a sorba állításhoz” tipp megjelenítése Leiratkozás Válasszon lapot - Megjegyzések megjelenítése - Kapcsolja ki a megjegyzések elrejtéséhez + Hozzászólások megjelenítése + Kapcsolja ki a hozzászólások elrejtéséhez Tartalom alapértelmezett országa Folytatás főnézetben Eltüntetés @@ -350,7 +334,7 @@ Az előrehaladás elveszett, mert a fájlt törölték Nincs hely az eszközön A NewPipe leállt a fájl feldolgozása közben - Utófeldolgozás sikertelen + Az utófeldolgozás sikertelen Nincs talalat A kiszolgáló nem fogad többszálú letöltést, próbálkozzon újra ezzel: @string/msg_threads = 1 A kiszolgáló nem küld adatokat @@ -366,7 +350,7 @@ Ilyen névű fájl már létezik Felülírás Egyedi név előállítása - Letöltés sikertelen + A letöltés sikertelen helyrehozás utófeldolgozás sorba állítva @@ -398,10 +382,9 @@ Automatikus sorba állítás Kapcsolja ki, hogy elrejtse a videó leírását és a további információkat Visszaállítja az alapértelmezéseket\? - Ez a videó korhatáros. -\nAz új, korhatáros videókkal kapcsolatos YouTube irányelvek miatt a NewPipe nem férhet hozzá a videóhoz, így nem tudja lejátszani. + Ez a videó korhatáros. \nAz új, korhatáros videókkal kapcsolatos YouTube irányelvek miatt a NewPipe nem férhet hozzá a videófolyamokhoz, így nem tudja lejátszani. Leírás - Megjegyzések + Hozzászólások Formázott jelentés másolása Adjon engedélyt a más alkalmazások feletti megjelenéshez Még nincs könyvjelző lejátszási listához @@ -421,8 +404,7 @@ Az ExoPlayer korlátai miatt az előre- és visszatekerés időtartama %d másodpercre lett állítva Csatornacsoportok Rendszer alapértelmezése - Az Általános adatvédelmi rendeletnek (GDPR) való megfelelés érdekében felhívjuk figyelmét a NewPipe adatvédelmi nyilatkozatára. Olvassa el figyelmesen. -\nEl kell fogadnia, ha hibajelentést szeretne küldeni. + Az Általános adatvédelmi rendeletnek (GDPR) való megfelelés érdekében felhívjuk figyelmét a NewPipe adatvédelmi irányelveire. Olvassa el figyelmesen. \nEl kell fogadnia, ha hibajelentést szeretne küldeni. Alkalmazás összeomlasztása Memóriaszivárgások megjelenítése A memóriaszivárgás-monitorozás az alkalmazás megállását okozhatja, amíg a dinamikus memória mentése folyik @@ -443,8 +425,7 @@ Senki sem nézi A feliratkozók száma nem érhető el Helyi - A nyelv az alkalmazás újraindításakor fog megváltozni - Megjegyzések betöltése sikertelen + A megjegyzések betöltése sikertelen Válasszon egy lejátszási listát Lejátszás automatikus indítása — %s Lejátszás sebességének beállítása @@ -458,7 +439,7 @@ © %1$s %2$s, %3$s licenc alatt Harmadik féltől származó licencek Kész - Nincs megjegyzés + Nincsenek hozzászólások ∞ videó 100+ videó Jelentés a GitHubon @@ -468,7 +449,7 @@ reCAPTCHA sütik törlése Előadók Albumok - Számok + Dalok Események Videók Ez a videó korhatáros. @@ -516,7 +497,7 @@ Ezt a tartalmat még nem támogatja a NewPipe. \n \nRemélhetőleg egy következő verzióban már támogatott lesz. - Nincs a készülékén olyan alkalmazás, amely meg tudja ezt nyitni + Az eszközön nincs olyan alkalmazás, amely meg tudja ezt nyitni Ez a videó csak YouTube Music Prémium előfizetők számára érhető el, így nem tekinthető meg és nem tölthető le a NewPipe-pal. Automatikus (rendszertéma) Ez a tartalom csak előfizetőknek érhető el, nem tekinthető meg és nem tölthető le a NewPipe-pal. @@ -536,7 +517,7 @@ A YouTube biztosít egy „Korlátozott módot”, amely elrejti a lehetséges felnőtteknek szóló tartalmat A YouTube „Korlátozott mód” bekapcsolása A példány már létezik - A példány érvényesítése nem sikerült + A példány érvényesítése sikertelen Adja meg a példány webcímét Példány hozzáadása Találjon Önnek tetsző példányokat itt: %s @@ -562,7 +543,7 @@ A fő lejátszó teljes képernyős indítása A videókat ne a kis lejátszóban indítsa el, hanem kapcsolja be a teljes képernyős módot, ha az automatikus forgatás zárolva van. Továbbra is elérheti a kis lejátszót, ha kilép a teljes képernyőből Szolgáltatás be/ki, jelenleg kiválasztott: - A megjegyzések ki vannak kapcsolva + A hozzászólások ki vannak kapcsolva Húzza oldalra az elemeket az eltávolításukhoz A következő sorba állítása A következő sorba állítva @@ -572,29 +553,17 @@ Tiltsa le a médiacsatornázást, ha fekete képernyőt vagy akadozást tapasztal videólejátszáskor. Minden letöltésnél meg fogja kérdezni, hogy hova mentse el Válasszon egy példányt - Lista legutóbbi frissítése: %s - Lista betöltése… + Hírfolyam utoljára frissítve: %s + Hírfolyam betöltése… Csak a nem csoportosított feliratkozások megjelenítése - Lista - Lista frissítési küszöb + Hírfolyam + Hírfolyam frissítési küszöb A legutóbbi frissítés óta eltelt idő, ami után a feliratkozás elavultnak számít – %s - A szerző fiókját eltávolították. -\nA NewPipe nem fogja tudni betölteni ezt a listát a jövőben. -\nLeiratkozik erről a csatornáról\? - A gyors listamód nem ad ennél több információt. - Lekérés egy dedikált listából, ha lehetséges + A szerző fiókját eltávolították. \nA NewPipe nem fogja tudni betölteni ezt a hírfolyamot a jövőben. \nLeiratkozik erről a csatornáról? + A gyors hírfolyammód nem szolgáltat több információt. + Lekérés egy dedikált hírfolyamból, ha lehetséges Gyors mód engedélyezése - Úgy gondolja, hogy a lista betöltése lassú\? Ha így van, akkor próbálja engedélyezni a gyors betöltést (ezt a beállításokban változtathatja meg, vagy a lenti gomb megnyomásával). -\n -\nA NewPipe két listabetöltési stratégiát kínál: -\n• A teljes feliratkozott csatorna lekérése, amely lassú, de teljes. -\n• Egy dedikált szolgáltatási végpont, amely gyors, de általában nem teljes. -\n -\nA különbség a kettő között az, hogy a gyorsból általában hiányoznak egyes információk, mint az elem hossza vagy a típusa (nem lehet megkülönböztetni az élő videókat a normálaktól), valamint kevesebb elemet adhat vissza. -\n -\nA YouTube például egy olyan szolgáltatás, amely ezt a gyors módot RSS hírcsatornával kínálja. -\n -\nÍgy a választása azon múlik, hogy melyiket tartja fontosabbnak: a sebességet vagy a pontos információkat. + Úgy gondolja, hogy a hírfolyam betöltése lassú? Ha így van, akkor próbálja engedélyezni a gyors betöltést (ezt a beállításokban változtathatja meg, vagy a lenti gomb megnyomásával). \n \nA NewPipe két hírfolyam betöltési stratégiát kínál: \n• A teljes feliratkozott csatorna lekérése, amely lassú, de teljes. \n• Egy dedikált szolgáltatási végpont, amely gyors, de általában nem teljes. \n \nA különbség a kettő között az, hogy a gyorsból általában hiányoznak egyes információk, mint az elem hossza vagy a típusa (nem lehet megkülönböztetni az élő videókat a normálaktól), valamint kevesebb elemet adhat vissza. \n \nA YouTube például egy olyan szolgáltatás, amely ezt a gyors módot RSS hírcsatornával kínálja. \n \nÍgy a választása azon múlik, hogy melyiket tartja fontosabbnak: a sebességet vagy a pontos információkat. Csatorna profilképének bélyegképe Legutóbbi Kiemelt @@ -612,25 +581,25 @@ Letöltés befejezve %s letöltés befejezve - Lista feldolgozása… + Hírfolyam feldolgozása… Egyes szolgáltatásoknál érhető el, általában sokkal gyorsabb, és korlátozott számú elemet adhat vissza, gyakran hiányos információkkal (például nincs hossz, elemtípus, vagy élő videó állapot) Fiók eltávolítva Megjelölés megnézettként Még nincs letöltési mappa beállítva, válassza ki az alapértelmezett letöltési mappát most Tekerősáv bélyegkép-előnézete Magas minőségű (nagyobb) - Hiba a lista betöltésekor + Hiba a hírfolyam betöltésekor Nyelv Támogatás Weboldal megnyitása Táblagép mód Az Android 10-től kezdve, csak a „Storage Access Framework” támogatott - Új listaelemek + Új hírfolyamelemek Privát Belső Készítő által szívecskézve Be - A(z) „%s” listája nem tölthető be. + A(z) „%s” hírfolyam nem tölthető be. Ez egy SoundCloud Go+ szám, legalábbis az Ön országában, így nem játszható le vagy tölthető le a NewPipe-pal. Kapcsolja ki, hogy elrejtse a metainformációs dobozokat, melyek további információkat tartalmaznak a közvetítés létrehozójáról, annak tartalmáról vagy egy keresési kérésről Hibajelentési értesítés @@ -668,9 +637,9 @@ %1$s letöltés törölve %1$s letöltés törölve - Rögzített megjegyzés + Kitűzött hozzászólás LeakCanary nem elérhető - Lejátszó értesítés + Lejátszási értesítés Módosítsa a progresszív tartalmak betöltési intervallumának méretét (jelenleg %s). Az alacsonyabb érték felgyorsíthatja a kezdeti betöltésüket. Jelenleg játszott közvetítés értesítésének testreszabása Értesítések @@ -678,14 +647,14 @@ Értesítések új élő közvetítésekről a feliratkozott csatornák esetén Közvetítés részleteinek betöltése.… Keressen új élő közvetítést - Új közvetítésértesítések + Új közvetítések értesítései Értesítésen új élő közvetítés esetén a feliratkozott csatornákhoz Ellenőrzési gyakoriság Szükséges hálózati kapcsolat Bármilyen hálózat Törli az összes letöltött fájlt a lemezről\? Értesítsen - Értesítéstek kikapcsolva + Az értesítések le vannak tiltva Lejátszás betöltési intervallumának mérete Százaléka @@ -693,14 +662,14 @@ %s új elő közvetítés ExoPlayer alapértelmezett - Feliratkoztál erre a csatornára + Feliratkozott erre a csatornára , Azok az élő adások melyek nem támogatottak a letöltő által, rejtve vannak A választott élő adást nem lehet külső lejátszóval lejátszani - Összes váltása - Külső lejátszók számára nem érhető el az hang csatorna - Külső lejátszók számára nem érhető el videó - Válassz minőséget külső lejátszókhoz + Összes be/ki + Külső lejátszók számára nem érhető el hangfolyam + Külső lejátszók számára nem érhető el videófolyamok + Válasszon minőséget a külső lejátszókhoz Ismeretlen formátum Ismeretlen minőség Félhang @@ -720,11 +689,11 @@ A kiszürkített lejátszólisták már tartalmazzák ezt az elemet. Állandó bélyegkép feloldása Ismétlődések eltávolítása - Végignézve - Részben megnézve + Teljesen megtekintett + Részben megtekintett Kártya Ez a beállítás csak a(z) %s téma esetén érhető el - Hardveresmédiagomb-események figyelmen kívül hagyása + Hardveres médialejátszó gombok eseményeinek figyelmen kívül hagyása A következő közvetítések megjelenítése Az eredeti hangsáv választása, a nyelvtől függetlenül A látássérülteknek szóló leírást tartalmazó hangsáv választása, ha van ilyen @@ -741,9 +710,7 @@ Az ExoPlayer dekódoló tartalék funkciójának használata Engedélyezze ezt a beállítást, ha dekóder előkészítési problémái vannak, ami alacsonyabb prioritású dekóderekre váltást okoz, ha az elsődleges dekóderek előkészítése sikertelen. Ez rosszabb lejátszási teljesítményt eredményezhet, mint az elsődleges dekóderek használata. Kerülőmegoldás: mindig az ExoPlayer videokimeneti felületének használata - Ez a kerülőmegoldás elengedi és újból előkészíti a videokodekeket, ha felületváltozás történik, ahelyett, hogy közvetlenül a kodeknél állítaná be a felületet. Ez már alapból használatban van egyes, az ezzel a problémával érintett eszközöknél, a beállításnak Android 6 vagy újabb esetén van hatása. -\n -\nA beállítás bekapcsolása megakadályozhatja a lejátszási hibákat, ha átváltja a jelenlegi videolejátszót, vagy teljes képernyőre vált. + Ez a kerülőmegoldás elengedi és újból előkészíti a videokodekeket, ha felületváltozás történik, ahelyett, hogy közvetlenül a kodeknél állítaná be a felületet. Ez már alapból használatban van egyes, az ezzel a problémával érintett eszközöknél, a beállításnak Android 6 vagy újabb esetén van hatása\n\nA beállítás bekapcsolása megakadályozhatja a lejátszási hibákat abban az esetben, ha átváltja a jelenlegi videolejátszót, vagy teljes képernyőre vált %1$s %2$s szinkronizált leíró @@ -757,18 +724,18 @@ Eltávolítja az összes ismétlődő közvetítést ebből a lejátszólistáról\? eredeti Kezdőlap pozíciója - A médiacsatornázás alapértelmezés szerint le van tiltva az Ön készülékén, mivel az Ön készülékmodellje nem támogatja azt. + A médiacsatornázás alapértelmezés szerint le van tiltva a saját eszközén, mivel a saját eszközmodellje nem támogatja azt. Kezdőlapválasztó alulra helyezése Nincs élő közvetítés Nincs adatfolyam Az alábbi értesítési műveletek szerkesztéséhez koppintson rá. Az első három műveletet (lejátszás/szünet, előző és következő) a rendszer állítja be, és nem szabhatók testre. Csatornalapok lekérése - A hírcsatorna frissítésekor lekérendő lapok. Ennek az opciónak nincs hatása, ha egy csatorna frissítése gyors módban történik. + A hírfolyam frissítésekor lekérendő lapok. Ennek a beállításnak nincs hatása, ha egy csatorna frissítése gyors módban történik. Miniatűrök - Feltöltő avatarjai - Alcsatorna avatarok - Avatarok - Bannerek + Feltöltő profilképei + Alcsatorna profilképei + Profilképek + Borítóképek Feliratkozók Csatornák Lejátszási listák @@ -777,13 +744,13 @@ Csatorna fülek Milyen lapok jelennek meg a csatornaoldalakon Lejátszási sor megnyitása - Képernyő tájolásának váltása - Teljes képernyőre váltás + Képernyő tájolás be/ki + Teljes képernyő be/ki Következő közvetítés Előző közvetítés Lejátszás Visszajátszás - További opciók + További lehetőségek Időtartam Visszatekerés Előre @@ -808,7 +775,7 @@ Webcímlista megosztása - %1$s: %2$s Videók - Dalok + Zeneszámok Rövidek Élő Nincs elég szabad hely az eszközön @@ -822,6 +789,16 @@ Az összes beállítás visszaállítása elveti az összes preferált beállítást, és újraindítja az alkalmazást. \n \nBiztosan folytatja? - Az importálandó exportban lévő beállítások sérülékeny formátumot használnak, amely a NewPipe 0.27.0-s verziója óta elavult. Győződjön meg arról, hogy megbízható forrásból importálja, és a jövőben csak a NewPipe 0.27.0-s vagy újabb verziójából származó exportokat használjon. A beállítások ebből a sérülékeny forrásból történő importálása hamarosan végleg el lesz távolítva, és a NewPipe régi verziói nem fogják tudni importálni az újabb verziókból származó exportokat. + Az importálandó exportban lévő beállítások sérülékeny formátumot használnak, amely a NewPipe 0.27.0-ás verziója óta elavult. Győződjön meg arról, hogy megbízható forrásból importálja, és a jövőben csak a NewPipe 0.27.0-ás vagy újabb verziójából származó exportokat használjon. A beállítások ebből a sérülékeny forrásból történő importálása hamarosan végleg el lesz távolítva, és a NewPipe régi verziói nem fogják tudni importálni az újabb verziókból származó exportokat. másodlagos + Megosztás YouTube ideiglenes lejátszási listaként + Lejátszási listák + Válasszon ki egy hírfolyamcsoportot + Még nincs létrehozott hírfolyamcsoport + Csatornacsoport-oldal + Keresés %1$s + Keresés %1$s (%2$s) + Kedvelések + SoundCloud Top 50 oldal eltávolítva + A SoundCloud megszüntette az eredeti Top 50-es listákat. A megfelelő lap el lett távolítva a főoldalról. diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 4117e3a18..4df522eda 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -82,9 +82,6 @@ Meminta kode reCAPTCHA Hitam Semua - r - J - T Buka pada mode sembulan Izin ini dibutuhkan untuk \nmembuka di mode sembul @@ -193,8 +190,8 @@ Terakhir Diputar Sering Diputar Konten halaman utama - Halaman Kosong - Halaman Kedai + Halaman kosong + Halaman kiosk Halaman saluran Pilih saluran Belum ada saluran langganan @@ -417,7 +414,6 @@ %s pendengar - Bahasa yang diubah akan diterapkan setelah aplikasi dimulai ulang Situs PeerTube Pilih situs PeerTube favorit Anda Temukan situs yang Anda suka di %s @@ -809,4 +805,14 @@ Cadangkan dan pulihkan Pengaturan dalam ekspor yang diimpor menggunakan format rentan yang tidak digunakan lagi sejak NewPipe 0.27.0. Pastikan ekspor yang diimpor berasal dari sumber tepercaya, dan lebih memilih hanya menggunakan ekspor yang diperoleh dari NewPipe 0.27.0 atau yang lebih baru di masa mendatang. Dukungan untuk mengimpor pengaturan dalam format rentan ini akan segera dihapus sepenuhnya, dan NewPipe versi lama tidak akan dapat lagi mengimpor pengaturan ekspor dari versi baru. sekunder + Daftar putar + Daftar putar + Halaman grup saluran + Belum ada grup umpan yang dibuat + Pilih grup umpan + Cari %1$s + Cari %1$s (%2$s) + Suka + Halaman Top 50 SoundCloud dihapus + SoundCloud telah menghentikan dukungan tangga lagu Top 50. Tab terkait telah dihapus dari halaman utama Anda. diff --git a/app/src/main/res/values-is/strings.xml b/app/src/main/res/values-is/strings.xml index c5d5d02c6..8d5c71a03 100644 --- a/app/src/main/res/values-is/strings.xml +++ b/app/src/main/res/values-is/strings.xml @@ -97,7 +97,6 @@ Hljóðstillingar Spila í bakgrunni Þegar hlekkur er opnaður — %s - þús. Líkar ekki við Reyna aftur Lýsing @@ -217,7 +216,6 @@ Athugasemd þín (á ensku): Engar niðurstöður Myndskeið - ma. Engin áhorf %s áhorf @@ -268,7 +266,6 @@ Nýlega spilað Mest spilað Aðalsíða - Tungumálið breytist þegar forritið er endurræst Flutt út Flutt inn Staðbundið @@ -353,7 +350,6 @@ Flokkur Merki NewPipe er þróað af sjálfboðaliðum sem eyða frítíma sínum í að færa þér bestu notendaupplifunina. Gefðu til baka til að hjálpa forriturum að gera NewPipe enn betri á meðan þeir njóta kaffibolla. - millj. Slökktu á til að fela lýsingu og viðbótarupplýsingar myndskeiðs Villa kom upp: %1$s Þraut reCAPTCHA @@ -802,4 +798,14 @@ Sýna villustiku Veldu gæði mynda og hvort eigi að hlaða myndum inn yfirhöfuð, til að minnka notun gagna og minnis. Breytingar munu hreinsa bæði vinnsluminni og diskminni - %s auka + Deila sem YouTube-bráðabirgðaspilunarlista + Spilunarlistar + Leita í %1$s + Leita í %1$s (%2$s) + Veldu hóp streyma + Enginn hópur streyma útbúinn ennþá + Síða rásahóps + Líkar við + Topp 50 síða SoundCloud fjarlægð + SoundCloud er hætt með Topp 50 vinsældalistann. Viðkomandi flipi hefur verið fjarlægður af aðalsíðunni þinni. diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 6767f8572..f7511b7c7 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -82,9 +82,6 @@ Risoluzione reCAPTCHA Nero Tutto - k - M - Mrd È richiesta la risoluzione del reCAPTCHA Apri in modalità popup Riproduzione in modalità popup @@ -427,7 +424,6 @@ %s ascoltatori %s ascoltatori - La lingua verrà cambiata al riavvio dell\'applicazione Contenuti in evidenza predefiniti Durata avanzamento e riavvolgimento rapidi Istanze PeerTube @@ -837,4 +833,14 @@ \nVuoi attivarlo? Le impostazioni nell\'export che viene importato usano un formato vulnerabile che è stato deprecato dalla versione 0.27.0 di NewPipe. Assicuratevi che l\'export importato venga da una fonte fidata, sarebbe preferibile usare solo exports ottenuti da NewPipe 0.27.0 o superiori, nel futuro. Il supporto all\'importazione di Impostazioni in questo formato vulnerabile sarà presto rimosso completamente, da quel momento le versioni di NewPipe più vecchie non saranno più in grado di importare impostazioni tramite export di versioni più recenti. secondaria + Condividi come playlist YouTube temporanea + Playlist + Seleziona un gruppo di feed + Ancora nessun gruppo di feed creato + Pagina gruppo canali + Cerca %1$s (%2$s) + Cerca su %1$s + Mi piace + Pagina Top 50 di SoundCloud rimossa + SoundCloud ha dismesso i grafici Top 50 originali. La scheda relativa è stata rimossa dalla pagina principale. diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 64e415559..910a6e6a3 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -83,9 +83,6 @@ reCAPTCHA を要求しました ブラック すべて - k - M - B ポップアップモードで開く ポップアップモードで開くには \n権限の許可が必要です @@ -418,7 +415,6 @@ %s 人が聴取中 - アプリを再起動すると、言語が変更されます 高速早送り/巻き戻し間隔 PeerTube インスタンス PeerTube インスタンスを選択する @@ -808,4 +804,6 @@ \n \n続行しますか? インポートされているエクスポートの設定は、NewPipe 0.27.0以降は非推奨であった脆弱な形式を使用します。 インポートされているエクスポートは信頼できる情報源からであり、将来的にはNewPipe 0.27.0かこれより新しいバージョンから得られるエクスポートのみを優先して使用します。 この脆弱な形式で設定をインポートするための対応はすぐに完全に削除され、新しいバージョンからエクスポートの設定をインポートすることは出来ません。 + YouTubeの一時的なプレイリストとして共有 + 二次的 diff --git a/app/src/main/res/values-ka/strings.xml b/app/src/main/res/values-ka/strings.xml index fd0b3f2ab..b74b22991 100644 --- a/app/src/main/res/values-ka/strings.xml +++ b/app/src/main/res/values-ka/strings.xml @@ -237,9 +237,6 @@ ვიდეო აუდიო ხელახლა სცადეთ - ათასი - მლნ - ბლნ სერვისის გადართვა, ამჟამად არჩეულია: გამოწერები არ არის @@ -333,7 +330,6 @@ გაფრთხილება: ყველა ფაილის იმპორტი ვერ მოხერხდა. ეს უგულებელყოფს თქვენს მიმდინარე პარამეტრს. კომენტარების ჩატვირთვა ვერ მოხერხდა - ენა შეიცვლება აპის გადატვირთვის შემდეგ ტრენდული ტოპ 50 ახალი და ცხელი diff --git a/app/src/main/res/values-kab/strings.xml b/app/src/main/res/values-kab/strings.xml index eccaaccb9..0ded7974c 100644 --- a/app/src/main/res/values-kab/strings.xml +++ b/app/src/main/res/values-kab/strings.xml @@ -1,7 +1,7 @@ - Tavidyutt akked d imesli - Γef + Avidyu akked d imesli + Ɣef & ISTEQSIYEN Amaynut Akk Sbedd asnas n Kore yexxuṣen\? @@ -82,7 +82,6 @@ Sider Asfaylu udhim Ttu - A Kter Ih Amazray @@ -90,7 +89,7 @@ Iseqdacen Sfeḍ isefka Ulac - Aḍris yettwanγel γef afus + Yettwanɣel ɣef afus Tibdarin n tɣuri Aneggaru yettwaslekmen Taɣuri tawurmant @@ -116,7 +115,7 @@ Beqqeḍ iwenniten Ameɣri Isaragen - Isedγiten + Ileqman Asentel Ubrik Bḍu @@ -128,10 +127,9 @@ Snifel isem Asider ur yeddi ara Tamwalit - o Aɣawas n deffir Amazray - Asteεfu + yesteɛfay Snifel isem Tividyutin Ldi deg uminig @@ -224,4 +222,5 @@ Amaṭṭaf Tibzimin Asebter d ilem - \ No newline at end of file + Iɣewwaṛen n ExoPlayer + diff --git a/app/src/main/res/values-kmr/strings.xml b/app/src/main/res/values-kmr/strings.xml index 9f19ced10..24bc5574e 100644 --- a/app/src/main/res/values-kmr/strings.xml +++ b/app/src/main/res/values-kmr/strings.xml @@ -49,9 +49,6 @@ Ne abone Karûbarê veguheztinê, niha hatî hilbijartin: - B - M - k Dîsa biceribîne Deng Vîdyo @@ -139,7 +136,6 @@ Pel hate jêbirin Betal bike Çareseriya çêtirîn - Gava ku sepanê ji nû ve dest pê kir dê ziman biguhere. Ouldîrove nehat barkirin Ma hûn dixwazin mîhengan jî îthal bikin\? Ev ê sazkirina xweya heyî ji holê rabike. diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 0bd08a5e4..8194bd0bc 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -116,9 +116,6 @@ 무엇:\\n요청:\\n콘텐츠 언어:\\n콘텐츠 국가:\\n앱 언어:\\n서비스:\\nGMT 시간:\\n패키지:\\n버전:\\nOS 버전: 결과 없음 구독할 항목을 추가하세요 - - 백만 - 십억 구독자 없음 구독자 %s명 @@ -418,7 +415,6 @@ %s명 청취중 - 앱이 다시 시작되면 언어가 변경됩니다 빠른-감기/되감기 찾는 시간 피어튜브 인스턴스 선호하는 PeerTube 인스턴스 선택 diff --git a/app/src/main/res/values-ku/strings.xml b/app/src/main/res/values-ku/strings.xml index 4b4c6f2c9..3dc51fcc8 100644 --- a/app/src/main/res/values-ku/strings.xml +++ b/app/src/main/res/values-ku/strings.xml @@ -133,9 +133,6 @@ ڤیدیۆ دەنگ هەوڵدانەوە - هەزار - ملیۆن - بلیۆن هیچ بەشداربوویەک نییە %s بەشداربوو @@ -398,7 +395,6 @@ فایلێک بەهەمان ناو هەیە فایلێکی دابەزێنراو بەم ناوەوە هەیە ناتوانرێ لەسەر ئەو فایلە جێگیربکرێ - زمان دەگۆڕدرێ لەدوای داخستن و پاشان کردنەوەی ئەپ. پەڕەی بنەڕەتی ماوەی خێرا بردنە پێشەوە\\ گێڕانەوە بۆ دواوە دۆخی پێرتووبی diff --git a/app/src/main/res/values-lt/strings.xml b/app/src/main/res/values-lt/strings.xml index 40fe8e8ad..a3da1721b 100644 --- a/app/src/main/res/values-lt/strings.xml +++ b/app/src/main/res/values-lt/strings.xml @@ -6,7 +6,7 @@ Įdiegti Atšaukti Atverti naršyklėje - Atverti iššokančio lango rėžime + Atverti iššokančio lango režime Dalintis Atsisiųsti Paieška @@ -139,9 +139,6 @@ Atstatoma po grotuvo klaidos Nėra rezultatų Čia nieko nėra išskyrus svirplius - Tūkst. - Mln. - Mlrd. Nėra prenumeratorių Nėra peržiūrų @@ -494,7 +491,6 @@ Labiausiai patikę Nesenai pridėta Vietinė - Kalba pasikeis paleidus programą iš naujo Komentarų įkelti nepavyko Ar norite importuoti nustatymus\? Kol kas nėra grojaraščio žymų @@ -836,4 +832,7 @@ \n%2$s \? - %1$s: %2$s + Grojaraščiai + Antrinis + Dalintis kaip laikinuoju youtube grojaraščiu diff --git a/app/src/main/res/values-lv/strings.xml b/app/src/main/res/values-lv/strings.xml index 9d99c14d6..06600d10a 100644 --- a/app/src/main/res/values-lv/strings.xml +++ b/app/src/main/res/values-lv/strings.xml @@ -136,9 +136,6 @@ Nav abonamentu Izvēlaties pakalpojumu, šobrīd izvēlēts: - B - M - k Atkārtot Audio Video @@ -272,7 +269,6 @@ Atrodiet instances, kas jums patīk ar %s Izvēlaties jūsu mīļākās PeerTube instances PeerTube serveri (instances) - Valoda nomainīsies, kad aplikāciju restartēs Neviena lietotne jūsu ierīcē nevar šo atvērt Nodaļas Jaunākie @@ -414,7 +410,7 @@ \n2. Dodieties uz šo URL:%1$s \n3. Ierakstaties, kad tiek prasīts \n4. Nokopējiet profila URL, uz kuru tikāt novirzīts. - YouTube abonementu importēšana no Google Takeout:\n\n1. Dodieties uz šo vietni: %1$s\n2. Autorizējieties, ja nepieciešams\n3. Noklikšķiniet uz \"Visi dati iekļauti\", pēc tam uz \"Atcelt visu atlasi\", pēc tam atlasiet tikai \"Abonementi\" un noklikšķiniet uz \"Labi\"\n4. Noklikšķiniet uz \"Nākamais solis\" un pēc tam uz \"Izveidot eksportu\"\n5. Pēc tam, kad tā parādās, noklikšķiniet uz pogas \"Lejupielādēt\"\n6. Noklikšķiniet IMPORTĒT DATNI zemāk un izvēlaties lejupielādēto .zip failu\n7. [Ja .zip failu neizdodas importēt] Izvelciet .csv failu (parasti zem \"YouTube un YouTube Music/subscriptions/subscriptions.csv\") no arhīva, tad noklikšķiniet uz IMPORTĒT DATNI zemāk un izvēlaties tikko izvilkto csv failu + Ievietot YouTube abonementus no Google Takeout:\n\n1. Dodieties uz šo vietni: %1$s\n2. Autorizējieties, ja nepieciešams\n3. Noklikšķiniet uz \"Visi dati iekļauti\", pēc tam uz \"Atcelt visu atlasi\", pēc tam atlasiet tikai \"Abonementi\" un noklikšķiniet uz \"Labi\"\n4. Noklikšķiniet uz \"Nākamais solis\" un pēc tam uz \"Izveidot eksportu\"\n5. Pēc tam, kad tā parādās, noklikšķiniet uz pogas \"Lejupielādēt\"\n6. Noklikšķiniet IEVIETOT DATNI zemāk un izvēlaties lejupielādēto .zip failu\n7. [Ja .zip failu neizdodas ievietot] Izvelciet .csv failu (parasti zem \"YouTube un YouTube Music/subscriptions/subscriptions.csv\") no arhīva, tad noklikšķiniet uz IEVIETOT DATNI zemāk un atlasiet tikko izvilkto csv failu Noklusējuma darbība, kad atver saturu — %s Pakalpojumu oriģinālteksti būs redzami video vienumos Pēc izdzēšanas, piespiedu kārtā ziņot par nepiegādātiem Rx izņēmumiem, ārpus fragmenta vai darbības dzīves cikla @@ -527,7 +523,7 @@ Atvērt pārlūkā Atcelt Uzstādīt - Netika atrasts video atskaņotājs (jūs varat uzstādīt VLC, lai to atskaņotu). + Netika atrasts video atskaņotājs (jūs variet uzstādīt VLC, lai to atskaņotu). Netika atrasts video atskaņotājs. Uzstādīt VLC? Publicēts %1$s Nospiediet uz meklēšanas ikonas, lai sāktu. @@ -560,7 +556,7 @@ Video lejupielādes mape Pievienot Fonā - Izvēlieties Cilni + Atlasiet cilni Saglabātie saraksti Abonementi Rādīt informāciju @@ -574,11 +570,11 @@ Noņem skaņu dažās izšķirtspējās Izmantot ārējo video atskaņotāju Kopīgot ar - Tiek rādīti rezultāti priekš: %s + Tiek rādīti %s rezultāti Vai jūs domājāt \"%1$s\"\? Iestatījumi Meklēt - Lejupielādēt video failu + Lejupielādēt video datni Lejupielādēt Populāri Lejupielāde ir sākusies @@ -643,7 +639,7 @@ Privātums Sarakstā neiekļauts Uzņēmums - Attālinātie meklēšanas ieteikumi + Servera meklēšanas ieteikumi Atzīmēt kā skatītu Apstrādā... Var aizņemt kādu laiku @@ -826,4 +822,7 @@ Kopīgot nosaukumus Importētā eksporta iestatījumi izmanto ievainojamo formātu, kas tika pārtraukts kopš NewPipe 0.27.0 versijas. Pārliecinieties, ka importētie dati ir no uzticama avota, un turpmāk ir vēlams izmantot tikai datus, kas veikti NewPipe 0.27.0 vai jaunākās versijās. Iestatījumu importēšanas atbalsts šajā neaizsargātajā formātā drīzumā tiks pilnībā aizvākts, un tad vecās NewPipe versijas vairs nevarēs importēt iestatījumus, kas veikti jaunajās versijās. Šis risinājums problēmas novēršanai atbrīvo un atkārtoti instantiē video kodekus, kad notiek virsmas maiņa, nevis tieši iestatīt virsmu kodekam. ExoPlayer jau izmanto šo risinājumu dažās ierīcēs, kurām ir šī problēma. Šis iestatījums darbosies tikai ierīcēs, kurās uzstādīta operētājsistēma Android 6 un jaunāka.\n\nIespējojot šo iestatījumu, var novērst atskaņošanas kļūdas, pārslēdzot pašreizējo video atskaņotāju vai pārejot uz pilnekrāna režīmu - \ No newline at end of file + Atskaņošanas saraksti + Kopīgot kā pagaidu YouTube atskaņošanas sarakstu + sekundārais + diff --git a/app/src/main/res/values-mk/strings.xml b/app/src/main/res/values-mk/strings.xml index ba41b730b..8b493ba08 100644 --- a/app/src/main/res/values-mk/strings.xml +++ b/app/src/main/res/values-mk/strings.xml @@ -146,9 +146,6 @@ Видео Звук Пробај повторно - илјади - M - милијарди Нема зачленети %s зачленет @@ -685,7 +682,6 @@ %s нови стримови Ве молиме, проверете дали веќе има дискусија за проблем како овој. Создадените дупликати ни одземаат од времето, коешто можеме да го посветиме на поправање на проблемот. - Јазикот ќе се смени откако апликацијата ќе биде рестартирана Додај го во редослед следното Нема гледачи Готово @@ -722,4 +718,4 @@ Изберете гестикулација за десната половина од екранот на плеерот Видеата нема да започнат со емитување во миниплеерот, туку директно ќе се вклучат на цел екран, доколку автоматското ротирање е заклучено. Сѐ уште можете да добиете пристап до миниплеерот, кога ќе излезете од целиот екран URL адресата не може да биде распознаена. Да се отвори со друга апликација? - \ No newline at end of file + diff --git a/app/src/main/res/values-ml/strings.xml b/app/src/main/res/values-ml/strings.xml index c6191ca7b..fb8a32c2e 100644 --- a/app/src/main/res/values-ml/strings.xml +++ b/app/src/main/res/values-ml/strings.xml @@ -81,7 +81,6 @@ പുതിയതും ചൂടേറിയതും മികച്ച 50 ട്രെൻഡിങ്ങ് - അപ്ലിക്കേഷൻ പുനരാരംഭിച്ചുകഴിഞ്ഞാൽ ഭാഷ മാറും കമെന്റുകൾ ലോഡുചെയ്യാനായില്ല ക്രമീകരണങ്ങളും ഇമ്പോർട്ടുചെയ്യാൻ നിങ്ങൾ ആഗ്രഹിക്കുന്നുണ്ടോ\? ഇത് നിങ്ങളുടെ നിലവിലെ സജ്ജീകരണത്തെ അസാധുവാക്കും. @@ -184,9 +183,6 @@ സബ്ക്രൈബേഴ്സ് ഇല്ല സേവനം മാറ്റുക, ഇപ്പോൾ തിരഞ്ഞെടുത്തത്: - B - k - M വീണ്ടും ശ്രമിക്കുക ഓഡിയോ വീഡിയോ diff --git a/app/src/main/res/values-mr/strings.xml b/app/src/main/res/values-mr/strings.xml index 304858d84..7a6b2eda4 100644 --- a/app/src/main/res/values-mr/strings.xml +++ b/app/src/main/res/values-mr/strings.xml @@ -138,11 +138,8 @@ डेबग अपडेट थेट - प्लेलिस्ट - फाईल - के परवाना चेकसम इतिहास diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index bb0527655..e70e61a74 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -171,9 +171,6 @@ Video Audio Cuba semula - K - J - B Tiada pelanggan %s pelanggan diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index 9e3916607..3cf92519a 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -89,9 +89,6 @@ Spiller av i oppsprettsmodus Alle Avskrudd - k - M - Mrd. Denne tilgangen trengs for \nåpning i oppsprettsmodus reCAPTCHA-oppgave forespurt @@ -422,7 +419,6 @@ %s lytter %s lyttere - Språket vil endres etter at appen har startet på nytt Standard kiosk PeerTube-instanser Lokal diff --git a/app/src/main/res/values-ne/strings.xml b/app/src/main/res/values-ne/strings.xml index 679b10846..b40145aa6 100644 --- a/app/src/main/res/values-ne/strings.xml +++ b/app/src/main/res/values-ne/strings.xml @@ -176,9 +176,6 @@ भिडियो अडियो पुन: प्रयास - हजार - करोड - अर्ब कुनै सदस्यहरू छैनन् %s सदस्य @@ -416,7 +413,6 @@ %s श्रोता %s श्रोताहरु - भाषा परिवर्तन एप पून:सुरु हुदा लागु हुनेछ। पूर्वनिर्धारित किओस्क (Kiosk) छिटो-अगाडि /-पछाडी खोज्न अवधि PeerTube उदाहरणहरू diff --git a/app/src/main/res/values-nl-rBE/strings.xml b/app/src/main/res/values-nl-rBE/strings.xml index d96dc0694..505351b52 100644 --- a/app/src/main/res/values-nl-rBE/strings.xml +++ b/app/src/main/res/values-nl-rBE/strings.xml @@ -147,9 +147,6 @@ Video Geluid Opnieuw proberen - k - M - mld. Geen abonnees %s abonnee @@ -486,7 +483,6 @@ Meest leuk gevonden Recent toegevoegd Lokaal - De taal zal veranderen zodra de app opnieuw is opgestart Geen afspeellijst bladwijzers Selecteer een afspeellijst Standaard kiosk @@ -551,7 +547,7 @@ Shuffle Herhaal Je kan maximaal drie acties selecteren om te tonen in de compacte notificatie! - Pas elke notificatie actie hieronder aan door er op te tikken. Selecteer tot drie acties die getoond worden in de compacte notificatie door gebruik te maken van de selectie vakjes aan de rechterkant + Pas elke notificatie actie hieronder aan door er op te tikken. Selecteer tot drie acties die getoond worden in de compacte notificatie door gebruik te maken van de selectie vakjes aan de rechterkant. Vijfde actie knop Vierde actie knop Derde actie knop @@ -580,7 +576,7 @@ \nAls gevolg van het nieuwe YouTube-beleid met video\'s met leeftijdsbeperkingen heeft NewPipe geen toegang tot deze videostreams en kan deze dus niet afspelen. Zet uit om videobeschrijving en extra informatie te verbergen Toon beschrijving - Nacht Thema + Nacht thema Open met Featured Deze inhoud is privé, waardoor het niet kan worden gestreamd of gedownload door NewPipe. @@ -628,4 +624,14 @@ Meldingen om fouten te rapporteren Verwerken... Dit kan even duren LeakCanary is niet beschikbaar + Verander de intervalgrootte voor het laden van progressieve inhoud (momenteel %s). Een lagere waarde kan het initiële laden versnellen + Afspeellijsten + Bewerk elke meldingsactie hieronder door erop te tikken. De eerste drie acties (afspelen/pauzeren, vorige en volgende) zijn ingesteld door het systeem en kunnen niet worden aangepast. + Intervalgrootte voor afspelen laden + Ja + Nee + Handig, bijvoorbeeld, als je een hoofdtelefoon gebruikte met kapotte fysieke knoppen + Verkies beschrijvende audio + Verkies originele audio + Selecteer het oorspronkelijke audiospoor, ongeacht de taal diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 00f095e39..5447ecf49 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -59,7 +59,7 @@ Video Geluid Opnieuw proberen - Druk op het vergrootglas om te beginnen. + Tik op het vergrootglas om te beginnen. Live Downloads Downloads @@ -68,7 +68,7 @@ Wat:\\nVerzoek:\\nInhoudstaal:\\nInhoudsland:\\nApp-taal:\\nDienst:\\nGMT-tijd:\\nPakket:\\nVersie:\\nVersie van besturingssysteem: Starten Pauzeren - Verwijderen + Verwijderen Controlesom Oké Bestandsnaam @@ -84,9 +84,6 @@ reCAPTCHA-uitdaging gevraagd Openen in pop-upmodus Alles - dznd. - mln. - mld. Deze machtiging is vereist om te \nopenen in pop-upmodus Speelt af in pop-upmodus @@ -420,7 +417,6 @@ %s luisteraar %s luisteraars - De taal zal veranderen zodra de app opnieuw is opgestart Standaard kiosk Duur voor-/achteruit spoelen PeerTube-instanties @@ -823,4 +819,9 @@ Instellingen resetten De instellingen in de export die wordt geïmporteerd, gebruiken een kwetsbaar formaat dat verouderd is sinds NewPipe 0.27.0. Zorg ervoor dat de export die wordt geïmporteerd afkomstig is van een vertrouwde bron, en geef er de voorkeur aan om in de toekomst alleen exporten te gebruiken die zijn verkregen van NewPipe 0.27.0 of nieuwer. Ondersteuning voor het importeren van instellingen in dit kwetsbare formaat zal binnenkort volledig worden verwijderd, en oude versies van NewPipe zullen dan geen export­instellingen meer uit nieuwe versies kunnen importeren. secundair + Delen als tijdelijke YouTube-afspeel­lijst + Afspeel­lijsten + Selecteer een feedgroep + Kanaalgroep­pagina + Nog geen feedgroep geselecteerd diff --git a/app/src/main/res/values-nqo/strings.xml b/app/src/main/res/values-nqo/strings.xml index e0b812410..fd32a527e 100644 --- a/app/src/main/res/values-nqo/strings.xml +++ b/app/src/main/res/values-nqo/strings.xml @@ -178,9 +178,6 @@ ߞߐߝߟߌ߫ ߕߍ߫ ߦߋ߲߬ ߡߍ߲ߕߊ ߞߵߊ߬ ߡߊߛߊ߬ߦߌ߬ - ߥߊ߯ - ߞߋ߲߬ - ߥߟߡ ߞߊ߬ ߥߏ߬ߦߏ߫ ߣߊ߬ߕߊ ߝߙߊ߬ ߕߎ߲߰ߠߌ߲ ߠߊ߫ ߞߍ߲ߖߘߍߡߊߓߟߏ ߡߊ߬ ߞߊ߬ ߕߎ߲߰ߠߌ߲ ߘߐߞߊ߬ߙߊ߲ ߓߟߏߕߎ߰ (ߞߊߣߊ߬ ߡߊߛߊ߬ߦߌ߬) ߥߏ߬ߦߏ߫ ߢߐ߲߰ߘߐ ߟߎ߫ ߟߊ߫ ߕߏߟߏ߲ߟߊ߲߫ ߥߊ߲߬ߥߊ߲ ߣߎߡߊ߲߫ ߕߟߊ ߖߍ߰ߙߍ ߛߎߥߊ߲ߘߌ߫ @@ -408,7 +405,6 @@ ߖߊ߲߬ߕߏ߬ߒߘߐ: ߞߐߕߐ߮ ߟߎ߬ ߓߍ߯ ߕߍ߫ ߛߋ߫ ߟߊߛߣߍ߫ ߟߊ߫. ߕߏߟߏ߲߫ ߛߙߍߘߍ ߘߏ߫ ߛߎߥߊ߲ߘߌ߫ ߕߏߟߏ߲߫ ߛߙߍߘߍ߫ ߟߊߞߎ߲߬ߘߎ߬ߣߍ߲߬ ߕߍ߫ ߝߟߐ߫ - ߞߊ߲ ߘߌߣߊ߬ ߡߊߝߊ߬ߟߋ߲߬ ߣߌ߫ ߟߥߊߟߌߟߊ߲ ߣߊ߬ ߘߊ߫ ߘߐߟߥߌ߬ ߛߊ߲ߞߊߥߟߌ ߟߎ߬ ߞߊ߲߬ߞߎߡߊ ߟߎ߬ ߕߍ߫ ߛߋ߫ ߟߊ߫ ߖߛߐ߫ ߟߊ߫ ߘߌ߲߬ߞߌ߬ߙߊ߬ߡߊ diff --git a/app/src/main/res/values-or/strings.xml b/app/src/main/res/values-or/strings.xml index 92e6cff37..2ac1ea97c 100644 --- a/app/src/main/res/values-or/strings.xml +++ b/app/src/main/res/values-or/strings.xml @@ -62,7 +62,6 @@ ଇତିହାସ ବିଲୋପ ଦେଖନ୍ତୁ ପ୍ଲେକ୍ ପୋଜିସନ୍ ଡିଲିଟ୍ ହୋଇଛି ସନ୍ଧାନ ଇତିହାସ ବିଲୋପ ହେଲା - ଆପ୍ ପୁନଃଆରମ୍ଭ ହେବା ପରେ ଭାଷା ପରିବର୍ତ୍ତନ ହେବ ପ୍ଲେଲିଷ୍ଟ ଥମ୍ବନେଲ ଭାବରେ ସେଟ୍ କରନ୍ତୁ ଫାଇଲ୍ କୁ ନବଲିଖନ କରିପାରିବ ନାହିଁ ଫାଇଲ୍ ସୃଷ୍ଟି ହୋଇପାରିବ ନାହିଁ @@ -438,7 +437,6 @@ ନାପସନ୍ଦ ମନ୍ତବ୍ୟ ଗୁଡିକ ବର୍ଣ୍ଣନା - ନିୟୁତ ସମାଧାନ ପ୍ଲେବେକ୍ ସ୍ପିଡ୍ ନିୟନ୍ତ୍ରଣ ଟେମ୍ପୋ @@ -528,7 +526,6 @@ ସମ୍ବନ୍ଧୀୟ ଆଇଟମ୍ ଗୁଡ଼ିକ ପୁନଃ ସଯାଇବାକୁ ଡ୍ରାଗ୍ କରନ୍ତୁ ବିରାମ - ଵୃନ୍ଦ କୌଣସି ଗ୍ରାହକ ନାହାଁନ୍ତି ସୃଷ୍ଟି କରନ୍ତୁ ବିବରଣୀ ପାଇଁ ଟ୍ୟାପ୍ କରନ୍ତୁ @@ -609,7 +606,6 @@ ବହିଃ-ଚାଳକ ପାଇଁ ଗୁଣବତ୍ତା ଚୟନ କରନ୍ତୁ ପିନ୍ ହୋଇଥିବା ମନ୍ତବ୍ୟ ୱେବସାଇଟ୍ ଖୋଲନ୍ତୁ - ହଜାର ସୂଚନା ପାଇବା… %s ଗ୍ରାହକ diff --git a/app/src/main/res/values-pa/strings.xml b/app/src/main/res/values-pa/strings.xml index 3d3975251..274733ecb 100644 --- a/app/src/main/res/values-pa/strings.xml +++ b/app/src/main/res/values-pa/strings.xml @@ -84,7 +84,7 @@ ਬੈਕਗ੍ਰਾਊਂਡ ਵਿੱਚ ਚੱਲ ਰਿਹਾ ਹੈ ਪੌਪ-ਅਪ ਮੋਡ ਵਿੱਚ ਚੱਲ ਰਿਹਾ ਹੈ ਸਮੱਗਰੀ - ਉਮਰ-ਮੁਤਾਬਕ-ਪਾਬੰਦੀਸ਼ੁਦਾ ਸਮੱਗਰੀ ਵਿਖਾਓ + ਉਮਰ ਮੁਤਾਬਕ ਪਾਬੰਦੀਸ਼ੁਦਾ ਸਮੱਗਰੀ ਵਿਖਾਓ ਲਾਈਵ ਡਾਊਨਲੋਡਸ ਡਾਊਨਲੋਡਸ @@ -153,13 +153,10 @@ ਵੀਡੀਓ ਆਡੀਓ ਦੋਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ - ਹਜ਼ਾਰ - ਮਿਲੀਅਨ - ਅਰਬ ਕੋਈ ਸਬਸਕ੍ਰਾਈਬਰ ਨਹੀਂ %s ਸਬਸਕ੍ਰਾਈਬਰ - %s ਸਬਸਕ੍ਰਾਈਬਰ + %s ਸਬਸਕ੍ਰਾਈਬਰਸ ਕੋਈ ਵਿਊ ਨਹੀਂ @@ -540,7 +537,6 @@ ਵਧੇਰੇ ਪਸੰਦ ਕੀਤੇ ਗਏ ਹਾਲ ਹੀ ਵਿੱਚ ਸ਼ਾਮਿਲ ਸਥਾਨਕ - ਭਾਸ਼ਾ ਐਪ ਨੂੰ ਦੋਬਾਰਾ ਚਲਾਉਣ \'ਤੇ ਬਦਲੇਗੀ ਪਲੇਲਿਸਟ ਚੁਣੋ ਹੋ ਗਿਆ ਹੱਲ ਕਰੋ @@ -613,8 +609,8 @@ ਆਨ , - ਡਾਉਨਲੋਡ ਮੁਕੰਮਲ - %s ਡਾਉਨਲੋਡ ਮੁਕੰਮਲ + %s ਡਾਉਨਲੋਡ ਮੁਕੰਮਲ + %s ਡਾਊਨਲੋਡਾਂ ਮੁਕੰਮਲ ਆਫ ਕਤਾਰ ਵਿੱਚ ਅੱਗੇ ਸ਼ਾਮਿਲ ਕਰੋ @@ -823,4 +819,12 @@ ਨਹੀਂ ਇੰਪੋਰਟ ਕੀਤੇ ਜਾ ਰਹੇ ਐਕਸਪੋਰਟ ਵਿੱਚ ਸੈਟਿੰਗਾਂ ਇੱਕ ਕਮਜ਼ੋਰ ਫਾਰਮੈਟ ਦੀ ਵਰਤੋਂ ਕਰਦੀਆਂ ਹਨ ਜੋ ਨਿਊਪਾਈਪ 0.27.0 ਤੋਂ ਬਰਤਰਫ਼ ਕੀਤਾ ਗਿਆ ਸੀ। ਯਕੀਨੀ ਬਣਾਓ ਕਿ ਇੰਪੋਰਟ ਕੀਤਾ ਜਾ ਰਿਹਾ ਨਿਰਯਾਤ ਇੱਕ ਭਰੋਸੇਯੋਗ ਸਰੋਤ ਤੋਂ ਹੈ, ਅਤੇ ਸਿਰਫ਼ ਨਿਊਪਾਈਪ 0.27.0 ਜਾਂ ਇਸਤੋਂ ਨਵੇਂ ਤੋਂ ਪ੍ਰਾਪਤ ਕੀਤੇ ਐਕਸਪੋਰਟ ਦੀ ਵਰਤੋਂ ਕਰਨ ਨੂੰ ਤਰਜੀਹ ਦਿਓ। ਇਸ ਕਮਜ਼ੋਰ ਫਾਰਮੈਟ ਵਿੱਚ ਸੈਟਿੰਗਾਂ ਨੂੰ ਆਯਾਤ ਕਰਨ ਲਈ ਸਮਰਥਨ ਜਲਦੀ ਹੀ ਪੂਰੀ ਤਰ੍ਹਾਂ ਹਟਾ ਦਿੱਤਾ ਜਾਵੇਗਾ ਅਤੇ ਫਿਰ ਨਿਊਪਾਈਪ ਦੇ ਪੁਰਾਣੇ ਸੰਸਕਰਣ ਹੁਣ ਨਵੇਂ ਸੰਸਕਰਣਾਂ ਤੋਂ ਐਕਸਪੋਰਟ ਦੀਆਂ ਸੈਟਿੰਗਾਂ ਨੂੰ ਇੰਪੋਰਟ ਕਰਨ ਦੇ ਯੋਗ ਨਹੀਂ ਹੋਣਗੇ। ਸੈਕੰਡਰੀ + ਅਸਥਾਈ ਯੂਟਿਊਬ ਪਲੇਲਿਸਟ ਵਜੋਂ ਸਾਂਝਾ ਕਰੋ + ਪਲੇਲਿਸਟਾਂ + %1$s ਦੀ ਖੋਜ ਕਰੋ + %1$s (%2$s) ٪1$s ਦੀ ਖੋਜ ਕਰੋ + ਫੀਡ ਗਰੁੱਪ ਚੁਣੋ + ਅਜੇ ਤੱਕ ਕੋਈ ਫੀਡ ਗਰੁੱਪ ਨਹੀਂ ਬਣਾਇਆ ਗਿਆ + ਚੈਨਲ ਗਰੁੱਪ ਪੰਨਾ + ਪਸੰਦ diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index e260cc887..8e70ad599 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -96,10 +96,7 @@ Wszystkie Wyłączone Wyczyść - tys. - mln - mld - To pozwolenie jest wymagane, aby + To pozwolenie jest wymagane, aby \notworzyć w trybie okienkowym Otwórz w trybie okienkowym Tryb okienkowy @@ -432,7 +429,6 @@ %s słuchaczy %s słuchaczy - Język zmieni się po ponownym uruchomieniu aplikacji Wielkość skoku przy przewijaniu Serwery PeerTube Wybierz swoje ulubione serwery PeerTube @@ -846,4 +842,14 @@ \nCzy chcesz to włączyć? Ustawienia w importowanym eksporcie korzystają z podatnego na ataki formatu, który został wycofany od wersji NewPipe 0.27.0. Upewnij się, że importowany eksport pochodzi z zaufanego źródła, i w przyszłości używaj wyłącznie eksportów uzyskanych z NewPipe 0.27.0 lub nowszego. Obsługa importowania ustawień w tym podatnym formacie zostanie wkrótce całkowicie usunięta, a wtedy starsze wersje NewPipe nie będą już mogły importować ustawień z eksportu z nowych wersji. dodatkowa + Udostępnij jako tymczasową playlistę YouTube + Playlisty + Wybierz grupę kanałów + Strona grupy kanałów + Nie utworzono jeszcze grupy kanałów + Szukaj %1$s + Szukaj %1$s (%2$s) + Polubienia + Usunięto stronę SoundCloud 50 najlepszych + SoundCloud wycofał oryginalną listę 50 najlepszych. Odpowiadająca karta została usunięta ze strony głównej. diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index e88e27a18..59ef66671 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -76,7 +76,7 @@ O site não pôde ser analisado Áudio Reproduzir no Kodi - Pesquisar + Buscar Mostrar opção para reproduzir o vídeo no Kodi Usar player de áudio externo Usar player de vídeo externo @@ -90,9 +90,6 @@ Reproduzindo em modo Popup Todos Desativado - mil - mi - bi Essa permissão é necessária \npara abrir em modo Popup Limpar @@ -101,7 +98,7 @@ Lembrar propriedades do Popup Lembrar último tamanho e posição do Popup Remove áudio em algumas resoluções - Sugestões de pesquisa + Sugestões de busca Escolha as sugestões a serem exibidas enquanto estiver buscando Melhor resolução Licenças de Terceiros @@ -111,7 +108,7 @@ Ver no GitHub Licença do NewPipe Se você tem ideias de: tradução, mudança no design, limpeza de código ou grandes mudanças de código — ajuda é sempre bem-vinda. Quanto mais se faz, melhor fica! - Ler licença + Ver licença Colaborar © %1$s %2$s protegido pela licença %3$s Sobre o NewPipe @@ -129,9 +126,9 @@ Inscrições Novidades Continuar reprodução - Continua vídeo após interrupções (ex: ligações) - Histórico de pesquisa - Armazena o histórico de pesquisa localmente + Continuar a reprodução após interrupções (por exemplo, chamadas telefônicas) + Histórico de busca + Armazena o histórico de busca localmente Histórico de exibição Mantenha o controle dos vídeos assistidos Histórico @@ -163,7 +160,7 @@ Player Nada aqui além de grilos Deseja excluir este item do histórico de busca\? - Conteúdo da página inicial + Conteúdo da tela inicial Página em branco Página do Kiosk Página do canal @@ -186,7 +183,7 @@ [Desconhecido] Reproduzir em segundo plano Reproduzir em um Popup - Doar + Fazer doação O NewPipe é desenvolvido por voluntários que usam seu tempo livre para trazer a você a melhor experiência de usuário. Retribua e ajude os desenvolvedores a tornarem o NewPipe ainda melhor enquanto eles desfrutam de uma xícara de café. Retribuir Site oficial @@ -208,7 +205,7 @@ Carregando conteúdo solicitado Importar base de dados Exportar base de dados - Substitui seu histórico atual, inscrições, playlists e (opcionalmente) configurações + Substitui seu histórico atual, inscrições, playlists e configurações (opcional) Exporta histórico, inscrições, playlists e configurações Exportado Importado @@ -291,7 +288,7 @@ Velocidade Afinação Desvincular (pode causar distorção) - Ação de \'abrir\' preferida + Ação preferida ao tocar em \'Abrir\' Ação padrão ao abrir conteúdo — %s Nenhum vídeo disponível para download Abrir gaveta @@ -302,10 +299,10 @@ Remove o histórico de vídeos assistidos e as posições de reprodução Remover todo o histórico de exibição? Histórico de exibição removido - Remover histórico de pesquisas - Remove o histórico de pesquisas - Remover todo o histórico de pesquisas? - Histórico de pesquisa removido + Remover histórico de buscas + Remove o histórico de buscas + Remover todo o histórico de buscas? + Histórico de busca removido 1 item excluído. NewPipe é um copyleft de software livre: Você pode usar, estudar, compartilhar e melhorar a seu gosto. Especificamente você pode redistribuir e/ou modificá-lo sob os termos da GNU General Public License como publicado pela Fundação de Software Livre, na versão 3 da Licença, ou (a seu critério) qualquer versão posterior. Você também quer importar as configurações? @@ -341,7 +338,7 @@ Notificações para novas versões do NewPipe Armazenamento externo indisponível Não é possível baixar para o cartão SD externo. Redefinir o local da pasta de download\? - Não foi possível ler as guias salvas, portanto, usamos as guias padrão + Não foi possível carregar as guias salvas, portanto, usamos as guias padrão Restaurar configurações Deseja restaurar os padrões? Número de inscritos indisponível @@ -428,7 +425,6 @@ %s ouvintes %s ouvintes - O idioma será alterado após reiniciar o aplicativo Duração de avanço/retrocesso rápido Instâncias PeerTube Selecione suas instâncias favoritas do PeerTube @@ -554,7 +550,7 @@ Nada Repetir Você pode selecionar até no máximo três botões para mostrar na notificação compacta! - Edite os botões de ação de notificação abaixo tocando em cada um. Selecione até três deles para serem mostrados na notificação compacta usando as caixas de seleção à direita. + Edite os botões de ação da notificação abaixo tocando em cada um. Selecione até três deles para serem mostrados na notificação compacta usando as caixas de seleção à direita. Quinto botão de ação Quarto botão de ação Terceiro botão de ação @@ -577,7 +573,7 @@ Calculando hash Notificações sobre o progresso do hashing de vídeo Notificar hash de vídeo - Desative para ocultar as caixas de informações de metadados com informações adicionais sobre o criador, conteúdo da transmissão ou uma solicitação de pesquisa + Desative para ocultar as caixas de informações de metadados com informações adicionais sobre o autor, conteúdo da transmissão ou uma solicitação de busca Mostrar informação de metadados Recente Capítulos @@ -638,9 +634,9 @@ Ativado Modo tablet Não mostrar - Baixa qualidade (pior) + Baixa qualidade (menor) Alta qualidade (melhor) - Pré visualização da miniatura da barra de busca + Pré visualização da miniatura na barra de busca Os comentários estão desabilitados Marcar como assistido Curtido pelo criador @@ -658,7 +654,7 @@ Enfileira a próxima Deslize os itens para remove-los Não inicie os vídeos no mini player, mas vá diretamente para o modo de tela cheia, se a rotação automática estiver bloqueada. Você ainda pode acessar o mini player saindo da tela cheia - Iniciar player principal em tela cheia + Iniciar reprodução principal em tela cheia Sugestões de busca remotas Sugestões de busca locais Processando… Pode demorar um pouco @@ -715,7 +711,7 @@ Formato desconhecido Qualidade desconhecida Tamanho do intervalo de carregamento da reprodução - Visualizar no site + Ver no site Se você está com problemas ao usar o aplicativo, confira estas respostas para perguntas comuns! Perguntas frequentes Classificar @@ -729,7 +725,7 @@ Falha ao copiar para a área de transferência Duplicata adicionada %d vez(es) As playlists em cinza já contêm este item. - Ignorar eventos de botão de mídia de hardware + Ignorar eventos de botões físicos (hardware) Útil, por exemplo, se você estiver usando um fone de ouvido com botões físicos quebrados Remover duplicados Remover duplicados\? @@ -838,4 +834,13 @@ \nTem certeza de que deseja continuar? As configurações na exportação que está sendo importada usam um formato vulnerável que foi descontinuado desde o NewPipe 0.27.0. Certifique-se de que a exportação que está sendo importada seja de uma fonte confiável e prefira usar apenas exportações obtidas do NewPipe 0.27.0 ou mais recente no futuro. O suporte para importação de configurações neste formato vulnerável será completamente removido em breve e as versões antigas do NewPipe não poderão mais importar configurações de exportações de novas versões. secundário + Playlists + Selecione um grupo de feeds + Nenhum grupo de feeds criado ainda + Página do grupo do canal + Pesquisar %1$s + Pesquisar %1$s (%2$s) + Curtidas + Página Top 50 do SoundCloud removida + O SoundCloud descontinuou as paradas originais do Top 50. A aba correspondente foi removida da sua página principal. diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 15c174cd8..b7fc0471c 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -229,7 +229,6 @@ OK Não foi possível atualizar a subscrição Sim e também os vídeos parcialmente vistos - M Ainda não há listas de reprodução favoritas %s ouvinte @@ -423,8 +422,6 @@ Importado Automático Substitui o seu histórico, subscrições, listas de reprodução e (opcionalmente) definições - k - MM Remover marcador Útil ao trocar para dados móveis, mas algumas transferências não podem ser suspensas Toque longo para colocar na fila @@ -527,7 +524,6 @@ Modificar escala de legendas e estilo de fundo. Tem que reiniciar a aplicação para aplicar as alterações Ação recusada pelo sistema Músicas - O idioma será alterado assim que reiniciar a app Adicionar instância Faixas Reproduzir no Kodi @@ -837,4 +833,11 @@ Não há espaço suficiente no aparelho As configurações na exportação a serem importadas usam um formato vulnerável depreciado desde NewPipe 0.27.0. Certifique-se de que a exportação que é importada é de uma fonte confiável e prefira usar apenas as exportações obtidas do NewPipe 0.27.0 ou mais recentes no futuro. O suporte para importar configurações neste formato vulnerável será removido em breve completamente e, em seguida, versões antigas do NewPipe não serão capazes de importar configurações de exportações de novas versões. secundário + Partilhar como lista de reprodução temporária do YouTube + Listas de reprodução + Selecione um grupo de feeds + Ainda nenhum grupo de feeds criado + Página do grupo do canal + Pesquisar %1$s + Pesquisar %1$s (%2$s) diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 6d53e9fd9..176baedaa 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -82,9 +82,6 @@ Abrir no modo popup Preto Tudo - K - M - MM Esta permissão é necessária \npara o modo popup Desafio reCAPTCHA @@ -281,7 +278,7 @@ Limpar histórico de visualizações Continuar (sem repetição) a fila de reprodução anexando um vídeo relacionado Mostrar dica \"Toque longo para colocar na fila\" - Mostrar dica ao premir em segundo plano ou no botão \"Detalhes\" da janela popup: + Mostrar dica ao premir em segundo plano ou no botão \"Detalhes:\" da janela popup Canais Listas de reprodução Faixas @@ -428,7 +425,6 @@ %s ouvintes %s ouvintes - O idioma será alterado assim que reiniciar a aplicação Duração de avanço/recuo rápido Instâncias PeerTube Defina as suas instâncias PeerTube preferidas @@ -652,7 +648,7 @@ %1$s descargas eliminadas - Descarga concluída + %s descarga concluída %s descargas concluídas %s descargas concluídas @@ -837,4 +833,11 @@ \nDeseja ativar essa opção? As configurações na exportação a serem importadas usam um formato vulnerável depreciado desde NewPipe 0.27.0. Certifique-se de que a exportação que é importada é de uma fonte confiável e prefira usar apenas as exportações obtidas do NewPipe 0.27.0 ou mais recentes no futuro. O suporte para importar configurações neste formato vulnerável será removido em breve completamente e, em seguida, versões antigas do NewPipe não serão capazes de importar configurações de exportações de novas versões. secundário + Partilhar como lista de reprodução temporária do YouTube + Listas de reprodução + Selecione um grupo de feeds + Ainda nenhum grupo de feeds criado + Página do grupo do canal + Pesquisar %1$s + Pesquisar %1$s (%2$s) diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 5b1238b0c..563cb9914 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -92,9 +92,6 @@ Dezactivat Aplicația/UI s-a oprit Ce:\\nSolicitare:\\nLimba conținutului:\\nȚara conținutului:\\nLimba aplicației:\\nServiciu:\\nOra GMT:\\nPachet:\\nVersiune:\\nVersiune SO: - k - mil. - mld. Elimină sunetul audio la anumite rezoluții Fundal Pop-up @@ -164,9 +161,9 @@ Istoric Doriți să ștergeți acest element din istoricul de căutare? Conținutul pagini principale - Pagină Goală + Pagină goală Pagina de chioșc - Pagină Canale + Pagină canale Alegeți un canal Nu v-ați abonat la niciun canal deocamdată Alegeți un chioșc @@ -559,7 +556,6 @@ Cele mai apreciate Adăugate recent Local - Limba se va schimba odată ce aplicația este repornită Nu există încă marcaje în playlist Selectați un playlist Chioșc implicit @@ -837,4 +833,14 @@ \nSigur doriți să continuați? Setările din exportul importat folosesc un format vulnerabil care a fost depreciat de la NewPipe 0.27.0. Asigurați-vă că exportul care este importat este dintr-o sursă de încredere și preferați să utilizați numai exporturi obținute din NewPipe 0.27.0 sau mai nou în viitor. Suportul pentru importul setărilor în acest format vulnerabil va fi în curând eliminat complet, iar versiunile vechi ale NewPipe nu vor mai putea importa setările exporturilor din versiunile noi. secundar + Distribuie ca listă de redare temporară YouTube + Liste de redare + Pagina grupului de canale + Caută: %1$s + Caută %1$s (%2$s) + Selectează un grup de fluxuri + Încă nu a fost creat niciun grup de fluxuri + Aprecieri + Pagina SoundCloud Top 50 a fost eliminată + SoundCloud a eliminat Top 50. Fila corespunzătoare a fost eliminată din pagina principală. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index a212d9888..e00df0413 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -94,9 +94,6 @@ Выберите отображаемые предложения поиска Отключено Убирает звук в некоторых разрешениях - млн - млрд - тыс. Разрешение всплывающего окна Помнить последние размер и позицию всплывающего окна Предложения поиска @@ -221,7 +218,7 @@ Загрузка запрошенного контента Скачать файл трансляции Показать сведения - Плейлисты + Плейлисты в закладках В плейлист Быстрый поиск позиции Выполняется быстрее, но менее точно. Не работает для перемотки на 5, 15 или 25 секунд @@ -434,7 +431,6 @@ %s слушателя %s слушателей - Язык будет изменён после перезапуска Шаг перемотки Серверы PeerTube Выберите предпочтительные серверы @@ -842,4 +838,14 @@ \nВы уверены, что хотите продолжить? Настройки в импортируемом экспорте используют уязвимый формат, который устарел с версии NewPipe 0.27.0. Убедитесь, что импортируемый экспорт получен из надёжного источника, и в будущем предпочтительнее использовать только экспорт, полученный из NewPipe 0.27.0 или новее. Поддержка импорта настроек в этом уязвимом формате скоро будет полностью удалена, и тогда старые версии NewPipe больше не смогут импортировать настройки из экспорта из новых версий. вторичный + Поделиться как временным плейлистом YouTube + Плейлисты + Страница группы каналов + Выберите группу кормов + Группа кормов еще не создана + Поиск %1$s + Поиск %1$s (%2$s) + Лайки + Страница SoundCloud Top 50 удалена + SoundCloud прекратил поддерживать оригинальные чарты Top 50. Соответствующая вкладка была удалена с вашей главной страницы. diff --git a/app/src/main/res/values-ryu/strings.xml b/app/src/main/res/values-ryu/strings.xml index 1a1383015..fcc889ea1 100644 --- a/app/src/main/res/values-ryu/strings.xml +++ b/app/src/main/res/values-ryu/strings.xml @@ -83,9 +83,6 @@ reCAPTCHAようきゅうさびたん ブラック まじり - k - M - B ポップアップモードっしふぃらちゅん ポップアップモードっしふぃらちゅんがー \nきんぎんぬきょかがふぃちようでぃす @@ -423,7 +420,6 @@ %sんかいんがちょうしゅちゅう %sんかいんがちょうしゅちゅう - アプリさいきちゃーしーねー、ぎんぐがへいるかんさりやびーん こうすくはやうくい/まきむどぅしかんかく PeerTubeインスタンス PeerTubeインスタンスさんたくすん diff --git a/app/src/main/res/values-sat/strings.xml b/app/src/main/res/values-sat/strings.xml index c1a5aacca..cce0ea919 100644 --- a/app/src/main/res/values-sat/strings.xml +++ b/app/src/main/res/values-sat/strings.xml @@ -216,8 +216,6 @@ ᱡᱟᱦᱟᱱ ᱡᱤᱱᱤᱥ ᱱᱚᱣᱟ ᱨᱮᱫᱚ ᱡᱟᱹᱥᱛᱤ ᱡᱟᱹᱥᱛᱤ ᱠᱨᱤᱠᱮᱴ ᱢᱮᱱᱟᱜᱼᱟ ᱾ ᱵᱷᱤᱰᱤᱭᱳ - k - M ᱥᱮᱞᱮᱫᱤᱭᱟᱹ ᱠᱚᱣᱟᱜ ᱞᱮᱠᱷᱟ ᱵᱟᱭ ᱦᱟᱹᱴᱤᱧ ᱟᱠᱟᱱᱟ ᱵᱟᱱᱩᱜ ᱧᱮᱞ ᱵᱷᱤᱰᱤᱭᱳ ᱵᱟᱹᱱᱩᱜᱼᱟ @@ -477,7 +475,6 @@ ᱱᱟᱣᱟ ᱟᱹᱨᱡᱤ ᱞᱟᱹᱜᱤᱫ ᱟᱹᱪᱩᱨ ᱢᱮ ᱚᱰᱤᱭᱳ ᱟᱨᱦᱚᱸ ᱯᱟᱲᱦᱟᱣ ᱢᱮ - ᱵᱤ ᱱᱤᱛᱚᱜ ᱵᱟᱪᱷᱟᱣ ᱟᱠᱟᱱ ᱴᱳᱜᱞ ᱥᱮᱵᱟ: ᱚᱵᱷᱤᱱᱮᱛᱟᱨ ᱵᱟᱹᱱᱩᱜᱼᱟ ᱚᱠᱚᱭ ᱦᱚᱸ ᱵᱟᱝ ᱧᱮᱞᱚᱜ ᱠᱟᱱᱟ @@ -519,7 +516,6 @@ ᱢᱤᱫ ᱯᱷᱟᱤᱞᱤᱥᱴ ᱵᱟᱪᱷᱟᱣ ᱢᱮ ᱤᱢᱯᱳᱨᱴ ᱱᱤᱛ ᱦᱟᱹᱵᱤᱡ playlist bookmarks ᱵᱟᱹᱱᱩᱜᱼᱟ - ᱮᱯ ᱮᱦᱚᱵ ᱞᱮᱠᱷᱟᱱ ᱯᱟᱹᱨᱥᱤ ᱵᱚᱫᱚᱞᱚᱜ-ᱟ ᱮᱥᱯᱟᱨᱴ ᱟᱠᱟᱱᱟ ᱴᱨᱮᱱᱰᱤᱝ ᱚᱰᱤᱭᱳ ᱥᱮᱴᱤᱝ diff --git a/app/src/main/res/values-sc/strings.xml b/app/src/main/res/values-sc/strings.xml index fc3558ea3..d0a910116 100644 --- a/app/src/main/res/values-sc/strings.xml +++ b/app/src/main/res/values-sc/strings.xml @@ -66,7 +66,6 @@ Noos e Populares Sos mègius 50 Tendèntzias - Sa limba at a mudare a pustis chi as a torrare a allùghere s\'aplicatzione Carrigamentu de sos cummentos fallidu Cheres fintzas importare sas impostatziones\? Custu at a subraiscrìere sas impostatziones tuas de como. @@ -166,9 +165,6 @@ Perunu iscritu Allughe/istuda su servìtziu. Ischertadu como: - Mrd - Mlln - mìg Torra a proare Àudio Vìdeu @@ -822,4 +818,7 @@ \n \nSes seguru de bòlere sighire? Sas impostatziones in s\'esportatzione chi benit importada impreant unu formadu vulneràbile chi est disusadu dae sa versione 0.27.0 de NewPipe. Assegura·ti chi s\'esportatzione importada bèngiat dae una fonte fidada, e preferi a impreare petzi esportatziones otentas dae NewPipe 0.27.0 o prus nou, in su benidore. Su suportu a s\'importatzione de impostatziones in custu formadu vulneràbile at a èssere luego bogadu de su totu, e dae cussu momentu sas versiones de NewPipe prus betzas no ant a èssere prus in gradu de importare impostatziones pro mèdiu de esportatziones de versiones prus noas. + Iscalitas + Cumpartzi comente un\'iscalita temporànea de YouTube + segundàriu diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 88f1b13f5..569011c86 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -13,12 +13,12 @@ Zdieľať s Použiť externý prehrávač videa Použiť externý prehrávač zvuku - Prevzaté video ukladať do - Prevzaté video súbory sú uložené tu + Adresár stiahnutých videí + Stiahnuté video súbory sú uložené tu Vyberte priečinok pre stiahnuté video Priečinok pre stiahnuté audio Vyberte priečinok pre audio súbory - Prevzaté zvukové súbory sú uložené tu + Stiahnuté zvukové súbory sú uložené tu Štandardné rozlíšenie Prehrať cez Kodi Nainštalovať chýbajúcu aplikáciu Kore\? @@ -74,17 +74,14 @@ Čakajte prosím… Skopírované do schránky Priečinok na sťahovanie zadefinujte prosím neskôr v nastaveniach - Prevzaté - Prevzaté + Sťahované súbory + Stiahnuté Hlásenie o chybe Aplikácia/UP zlyhalo Čo:\\nPožiadavka:\\nJazyk obsahu:\\nKrajina Obsahu:\\nJazyk Aplikácie:\\nSlužba:\\nČas v GMT:\\nBalík:\\nVerzia:\\nOS: Výzva reCAPTCHA Čierna Všetko - k - M - B Požiadavka reCAPTCHA Otvoriť vo vyskakovacom okne Tieto práva sú potrebné pre @@ -165,7 +162,7 @@ Nebol nájdený žiadny prehrávač pre stream (môžete si nainštalovať napr. VLC). Stiahnuť súbor streamu Zobraziť info - Uložené zoznamy + Uložené playlisty Pridať do Zobrazovať tip \"Pridať podržaním\" Zobrazí tip pri stlačení tlačidiel pozadia alebo vyskakovacieho okna videa \"Podrobnosti:\" @@ -179,8 +176,8 @@ Prepnúť na Video Importovať databázu Exportovať databázu - Prepíše aktuálnu históriu, odbery, zoznamy skladieb a (voliteľne) nastavenia - Exportuje históriu, odbery, zoznamy skladieb a nastavenia + Prepíše aktuálnu históriu, odbery, playlisty a (voliteľne) nastavenia + Exportuje históriu, odbery, playlisty a nastavenia Nepodarilo sa prehrať tento stream Pri prehrávaní došlo k chybe a nemožno pokračovať Zotavovanie po chybe v prehrávaní @@ -200,7 +197,7 @@ Naposledy prehrávané Najprehrávanejšie Obsah na hlavnej stránke - Prázdna strana + Prázdna stránka Kiosk Kanál Vyberte si kanál @@ -228,17 +225,17 @@ Vždy sa opýtať Získavajú sa informácie… Načítanie požadované obsahu - Nový zoznam skladieb + Nový playlist Premenovať Názov - Pridať do zoznamu skladieb - Nastaviť ako miniatúru zoznamu skladieb - Záložka zoznamu skladieb - Odstrániť Záložku - Odstrániť tento zoznam skladieb\? - Zoznam skladieb vytvorený + Pridať do playlistu + Nastaviť ako miniatúru playlistu + Pridať playlist medzi záložky + Odstrániť záložku + Odstrániť tento playlist? + Playlist bol vytvorený V playliste - Miniatúra zoznamu skladieb bola zmenená. + Miniatúra playlistu bola zmenená. Bez titulkov Prispôsobiť Vyplniť @@ -321,7 +318,7 @@ Bez limitu Limitovať rozlíšenie pri použití mobilných dát Kanály - Zoznamy skladieb + Playlisty Skladby Používatelia Pretáčať tiché pasáže @@ -428,7 +425,6 @@ %s poslucháči %s poslucháčov - Zmena jazyka sa prejaví po reštarte aplikácie Dĺžka rýchleho pretáčania Inštancie PeerTube Vyberte si svoje obľúbené inštancie PeerTube @@ -514,8 +510,7 @@ \n \nMožno v budúcnosti sa to zmení. Áno aj čiastočne pozreté videá - Pozreté videá, ktoré ste pozreli pred a po ich pridaní do zoznamu, budú odstránené. -\nSte si istí ich odstránením zo zoznamu\? Táto operácia je nezvratná! + Pozreté videá, ktoré ste pozreli pred a po ich pridaní do playlistu, budú odstránené. \nSte si istí ich odstránením z playlistu? Táto operácia je nezvratná! Odstrániť pozreté videá\? Odstrániť pozreté Pôvodné texty zo služieb budú viditeľné v položkách streamu @@ -535,14 +530,14 @@ Nahlásiť na GitHub-e Kopírovať formátované hlásenie Zobrazujú sa výsledky pre: %s - Zoznamy skladieb + Zoznam playlistov Zobraziť iba nezoskupené odbery Nikdy Iba na WiFi Spustí automatické prehrávanie - %s Prehrať zoznam - Zatiaľ bez záložiek zoznamu - Vyberte zoznam skladieb + Žiadne záložky playlistov + Vyberte playlist Skontrolujte prosím, či rovnaká chyba už nie je nahlásená. Vytváranie duplicitných hlásení komplikuje prácu vývojárov. Nemožno rozpoznať URL. Otvoriť pomocou inej aplikácie\? Automatický rad @@ -721,7 +716,7 @@ Ak máte problémy s používaním aplikácie, určite si prečítajte tieto odpovede na časté otázky! Vypnutie trvalého náhľadu Kopírovanie do schránky zlyhalo - Zoznamy zobrazené šedou farbou už obsahujú danú položku. + Playlisty zobrazené šedou farbou už obsahujú danú položku. Karta Dotykom stiahnite %s Duplikát bol pridaný %d-krát @@ -748,7 +743,7 @@ Preferovať prehrávanie popisu Zvuk: %s Zvuková stopa - Chcete odstrániť všetky duplikátne streamy z tohoto zoznamu\? + Chcete odstrániť všetky duplikátne streamy z tohoto playlistu? Zobrazovať nasledovné streamy V tomto streame by už mala byť prítomná zvuková stopa Výber zvukovej stopy pre externé prehrávače @@ -787,7 +782,7 @@ Dozadu Opäť prehrať Karty, ktoré sa majú načítať pri aktualizácii informačného kanála. Táto možnosť nemá žiadny účinok, ak je kanál aktualizovaný pomocou rýchleho režimu. - Zoznamy skladieb + Playlisty Presunie výber hlavnej karty do spodnej časti Žiadne živé prenosy Prehrať @@ -837,4 +832,14 @@ \nChcete to povoliť? Nastavenia v importovanom exporte používajú zraniteľný formát, ktorý bol od verzie NewPipe 0.27.0 zrušený. Uistite sa, že importovaný export pochádza z dôveryhodného zdroja, a v budúcnosti radšej používajte len exporty získané z verzie NewPipe 0.27.0 alebo novšej. Podpora importu nastavení v tomto zraniteľnom formáte bude čoskoro úplne odstránená a potom už staré verzie programu NewPipe nebudú môcť importovať nastavenia exportov z nových verzií. sekundárny + Zdieľať ako dočasný playlist YouTube + Playlisty + Vybrať skupinu kanálov + Skupina kanálov zatiaľ nie je vytvorená + Stránka skupiny kanálov + Hľadať %1$s (%2$s) + Hľadať %1$s + Páči sa + SoundCloud Top 50 stránka odstránená + SoundCloud prestal používať pôvodnú Top 50. Daná stránka bola odstránená z hlavnej stránky. diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index b0fcec406..474be8127 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -83,9 +83,6 @@ Predmet:\\nZahteva:\\nJezik vsebine:\\nDržava vsebine:\\nJezik aplikacije:\\nStoritev:\\nČas v GMT:\\nPaket:\\nRazličica:\\nRazličica OS: Črna Vse - k - mio - mrd Odpri v pojavnem načinu To dovoljenje je potrebno za odpiranje \nv pojavnem načinu @@ -253,7 +250,6 @@ Najbolj všečkan Dodano nedolgo nazaj Lokalno - Jezik bo spremenjen ob ponovnem zagonu aplikacije Ni mogoče naložiti komentarjev Ni veljavne ZIP datoteke Uvoženo @@ -460,4 +456,4 @@ Označi kot že ogledano Uporabite hitro nenatančno iskanje Sesuj predvajalnik - \ No newline at end of file + diff --git a/app/src/main/res/values-so/strings.xml b/app/src/main/res/values-so/strings.xml index 7577cfba9..de5178842 100644 --- a/app/src/main/res/values-so/strings.xml +++ b/app/src/main/res/values-so/strings.xml @@ -139,7 +139,6 @@ Shiddan Cusub oo Shiddan 50ka Sare - Luuqadu waxay isbaddali doontaa marka appka dib loo soo kiciyo Faallooyinka lama soo kicin karo Inaad sidoo kale fadhiga soo galiso ma rabtaa\? Tani waxay baddali fadhiga siduu kuu yahay hadda. @@ -243,9 +242,6 @@ Dad rukuntay ma jiraan Furo adeega, hada waxaa dooran: - B - K - M ku celi Dhagaysi Muuqaal diff --git a/app/src/main/res/values-sq/strings.xml b/app/src/main/res/values-sq/strings.xml index 873234e23..9aac66de7 100644 --- a/app/src/main/res/values-sq/strings.xml +++ b/app/src/main/res/values-sq/strings.xml @@ -207,7 +207,6 @@ Të rejat dhe të nxehtat Top 50 E trendit - Gjuha do të ndryshojë sapo aplikacioni të riniset Nuk mundën të ngarkohen komentet A dëshironi që të importoni dhe aranzhimet gjithashtu\? Kjo do të mbishkruajë strukturimin tuaj të tanishëm. @@ -296,9 +295,6 @@ Nuk ka abonues Aktivizoje shërbimin, momentalisht e zgjedhur: - B - M - k Riprovo Audio Video diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index e212a480b..08726379f 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -1,7 +1,7 @@ Објављено %1$s - Нема плејера стримова. Инсталирати VLC\? + Нема извођача довода. Да ли уградити ВЛЦ? Инсталирај Откажи Отвори у прегледачу @@ -60,7 +60,7 @@ Аудио снимак Покушај поново Уживо - Додирните лупу да бисте започели. + Додирните лупу ради почетка. Почни Паузирај Избриши @@ -83,10 +83,7 @@ Решите „reCAPTCHA“ задатак Црна Све - хиљ. - мил. - млрд. - Отвори у искачућем режиму + Отвори у искачућем облику Ова дозвола је потребна за \nотварање у искачућем режиму Пуштање у режиму искачућег прозора @@ -181,7 +178,7 @@ Уклони Детаљи Подешавања аудио снимка - Није пронађен ниједан плејер стримова (можете инсталирати VLC да бисте га покренули). + Није пронађен ниједан извођач довода (можете уградити ВЛЦ ради извођења садржаја). Преузимање фајла стрима Прикажи информације Обележене плејлисте @@ -368,7 +365,6 @@ Највише свиђања Недавно додато Локално - Језик ће бити промењен након рестартовања апликације Није могуће учитати коментаре Желите ли да увезете и подешавања\? Ово ће заменити ваше тренутно подешавање. @@ -376,8 +372,8 @@ Нема важећег ZIP фајла Увезено Извезено - Још нема обележивача на листи - Изаберите листу пуштања + Још нема обележивача плејлисте + Изаберите плејлисту Подразумевани киоск Које картице се приказују на главној страници Највише пуштано @@ -411,7 +407,7 @@ Нико не гледа Број пратилаца није доступан - Изабери услугу, тренутно изабрана: + Изаберите услугу, тренутно изабрана: Превуците за преуређивање Опис Коментари @@ -515,7 +511,7 @@ Мешање Понављање Можете да изаберете највише три радње за приказ у компактном обавештењу! - Измените сваку радњу обавештења додиром на њу. Означите до три радње које ће се приказивати у компактном обавештењу помоћу поља за потврду са десне стране + Измените сваку радњу обавештења додиром на њу. Означите до три радње које ће се приказивати у компактном обавештењу помоћу поља за потврду са десне стране. Дугме пете радње Дугме четврте радње Дугме треће радње @@ -837,4 +833,10 @@ \nЖелите ли да омогућите ово? Подешавања у извозу који се увозе користе рањив формат који је застарео од NewPipe верзије 0.27.0. Уверите се да извоз који се увози долази из поузданог извора и радије користите само извозе добијене из NewPipe 0.27.0 или новије верзије, у будућности. Подршка за увоз подешавања у овом рањивом формату ће ускоро бити потпуно уклоњена и тада старе NewPipe верзије више неће моћи да увозе подешавања извоза из нових верзија. секундарни + Дели као YouTube привремену плејлисту + Плејлисте + Још није направљена ниједна група фидова + Изаберите групу фидова + Страница групе канала + Ликовања diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index de74c27f7..1e3df00fc 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -112,9 +112,6 @@ Video Ljud Försök igen - t - mn - md Inga prenumeranter %s prenumerant @@ -436,7 +433,6 @@ Lokala Klar Slå på YouTubes \"Begränsat läge\" - Språket ändras när appen startas om Det gick inte att läsa in kommentarerna Inaktivera snabbläge Aktivera snabbläge @@ -823,4 +819,9 @@ \nÄr du säker på att du vill fortsätta? Inställningarna i exporten som importeras använder ett sårbart format som fasades ut i NewPipe 0.27.0. Se till att exporten som importeras är från en betrodd källa, och överväg att endast använda exporter som erhållits från NewPipe 0.27.0 eller nyare i framtiden. Stöd för import av inställningar från detta sårbara format kommer snart att tas bort helt, och sedan kommer gamla versioner av NewPipe inte att kunna importera inställningar av exporter från nya versioner längre. sekundär + Dela som YouTube temporär spellista + Spellistor + Välj en flödesgrupp + Ingen flödesgrupp har skapats ännu + Kanalgruppsida diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index 0056f7416..7221ab74d 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -145,8 +145,8 @@ %s பார்வைகள் - %sகாணொளிகள் - %sகாணொளிகள் + %s காணொளி + %s காணொளிகள் முதற்பக்கத்துக்கு மாற்று எந்த காணொலியும் இல்லை @@ -245,7 +245,6 @@ உம் அபிமான பியர்டியூப் நிகழ்வுகளைத் தேர்ந்தெடு உள்ளடக்க இயல்பிருப்பு மொழி இயக்குதலைத் மறுதொடர் - நி நிகழ்வு ஏற்கனவே உள்ளது யூடியூபின் \"கட்டுப்பாடு பயன்முறை\"ஐ இயக்கு பாடல்கள் @@ -260,8 +259,6 @@ என்ன:\\nகோரிக்கை:\\nஉள்ளடக்க மொழி:\\nஉள்ளடக்க நாடு:\\nசெயலி மொழி:\\nசேவை:\\nGMT நேரம்:\\nசிப்பம்:\\nபதிப்பு:\\nOS பதிப்பு: காணொளியை இயக்கு, காலவளவு: கருத்தளிப்புகள் - - ப.ல இயக்கியைச் சிதை பட்டியல்களில் இயக்கக குறியட நிலைகாட்டிகளைக் காட்டு துணையியக்கியில் காணொளிகளை துவக்காதே, ஆனால் தானாக சுழற்றல் பூட்டப்பட்டிருந்தால் நேரடியாக முழுதிரைக்குத் திரும்பு. முழுதிரையை வெளியேறி நீங்கள் இன்னும் துணையியக்கியை அணுகலாம் @@ -459,7 +456,7 @@ அண்மைக் கால பாடங்கள் அகவை வரம்பு - கூகிள் டேக்அவுட்டிலிருந்து YouTube சந்தாக்களை இறக்குமதி செய்க:\n\n 1. இந்த முகவரி க்குச் செல்லுங்கள்: %1$s\n 2. கேட்டபோது உள்நுழைக\n 3. \"அனைத்து தரவுகளும் சேர்க்கப்பட்டுள்ளன\" என்பதைக் சொடுக்கு செய்க, பின்னர் \"அனைத்தையும் தேர்வு செய்யுங்கள்\", பின்னர் \"சந்தாக்கள்\" மட்டுமே தேர்ந்தெடுத்து \"சரி\" என்பதைக் சொடுக்கு செய்க\n 4. \"அடுத்த படி\" என்பதைக் சொடுக்கு செய்து, பின்னர் \"ஏற்றுமதி உருவாக்கு\"\n 5. \"பதிவிறக்கு\" பொத்தானைக் சொடுக்கு செய்த பிறகு சொடுக்கு செய்க\n 6. கீழே உள்ள இறக்குமதி கோப்பைக் சொடுக்கு செய்து பதிவிறக்கம் செய்யப்பட்ட .zip கோப்பைத் தேர்ந்தெடுக்கவும்\n 7. + கூகிள் டேக்அவுட்டிலிருந்து YouTube சந்தாக்களை இறக்குமதி செய்க:\n\n 1. இந்த முகவரி க்குச் செல்லுங்கள்: %1$s\n 2. கேட்டபோது உள்நுழைக\n 3. \"அனைத்து தரவுகளும் சேர்க்கப்பட்டுள்ளன\" என்பதைக் சொடுக்கு செய்க, பின்னர் \"அனைத்தையும் தேர்வு செய்யுங்கள்\", பின்னர் \"சந்தாக்கள்\" மட்டுமே தேர்ந்தெடுத்து \"சரி\" என்பதைக் சொடுக்கு செய்க\n 4. \"அடுத்த படி\" என்பதைக் சொடுக்கு செய்து, பின்னர் \"ஏற்றுமதி உருவாக்கு\"\n 5. \"பதிவிறக்கு\" பொத்தானைக் சொடுக்கு செய்த பிறகு சொடுக்கு செய்க\n 6. கீழே உள்ள இறக்குமதி கோப்பைக் சொடுக்கு செய்து பதிவிறக்கம் செய்யப்பட்ட .zip கோப்பைத் தேர்ந்தெடுக்கவும்\n 7.[.zip இறக்குமதி தோல்வியடைந்தால்] .csv கோப்பை பிரித்தெடுக்கவும் (வழக்கமாக \"YouTube மற்றும் YouTube Music/subscriptions/subscriptions.csv\" என்பதன் கீழ்), கீழே உள்ள IMPORT FILE என்பதைக் கிளிக் செய்து, பிரித்தெடுக்கப்பட்ட csv கோப்பைத் தேர்ந்தெடு பிளேலிச்ட்டில் சேர்க்கப்படுவதற்கு முன்னும் பின்னும் பார்க்கப்பட்ட வீடியோக்கள் அகற்றப்படும்.\n நீங்கள் உறுதியாக இருக்கிறீர்களா? இதை செயல்தவிர்க்க முடியாது! இந்த செயலுக்கு பொருத்தமான கோப்பு மேலாளர் எதுவும் கிடைக்கவில்லை.\n சேமிப்பக அணுகல் கட்டமைப்பு இணக்கமான கோப்பு மேலாளரை நிறுவவும் இரண்டாம் நிலை @@ -602,7 +599,7 @@ மேலும் செய்தி மற்றும் செய்திகளுக்கு நியூபைப் வலைத்தளத்தைப் பார்வையிடவும். நியூபைப் திட்டம் உங்கள் தனியுரிமையை மிகவும் தீவிரமாக எடுத்துக்கொள்கிறது. எனவே, பயன்பாடு உங்கள் அனுமதியின்றி எந்த தரவையும் சேகரிக்காது.\n நீங்கள் ஒரு செயலிழப்பு அறிக்கையை அனுப்பும்போது என்ன தரவு அனுப்பப்படுகிறது மற்றும் சேமிக்கப்படுகிறது என்பதை நியூபிப்பின் தனியுரிமைக் கொள்கை விரிவாக விளக்குகிறது. நியூபைப்பின் உரிமம் - நியூபைப் என்பது நகலெடுக்கப்பட்ட லிப்ரே மென்பொருள்: நீங்கள் அதைப் பயன்படுத்தலாம், படிக்கலாம், பகிரலாம் மற்றும் மேம்படுத்தலாம். குறிப்பாக நீங்கள் இலவச மென்பொருள் அறக்கட்டளையால் வெளியிடப்பட்ட குனு பொது பொது உரிமத்தின் விதிமுறைகளின் கீழ் மறுபகிர்வு மற்றும்/அல்லது மாற்றியமைக்கலாம், உரிமத்தின் பதிப்பு 3 அல்லது (உங்கள் விருப்பத்தில்) பின்னர் எந்த பதிப்பையும் மாற்றலாம். + நியூபைப் என்பது நகலெடுக்கப்பட்ட லிப்ரே மென்பொருள்: நீங்கள் அதைப் பயன்படுத்தலாம், படிக்கலாம், பகிரலாம் மற்றும் மேம்படுத்தலாம். குறிப்பாக நீங்கள் இலவச மென்பொருள் அறக்கட்டளையால் வெளியிடப்பட்ட குனு பொது பொதுமக்கள் உரிமத்தின் விதிமுறைகளின் கீழ் மறுபகிர்வு மற்றும்/அல்லது மாற்றியமைக்கலாம், உரிமத்தின் பதிப்பு 3 அல்லது (உங்கள் விருப்பத்தில்) பின்னர் எந்தப் பதிப்பையும் மாற்றலாம். பயன்பாட்டைப் பயன்படுத்துவதில் சிக்கல் இருந்தால், பொதுவான கேள்விகளுக்கு இந்த பதில்களைப் பார்க்கவும்! இணையதளத்தில் காண்க கடைசியாக விளையாடியது @@ -616,7 +613,6 @@ செல்லுபடியாகும் சிப் கோப்பு இல்லை இது உங்கள் தற்போதைய அமைப்பை மேலெழுதும். கருத்துகளை ஏற்ற முடியவில்லை - பயன்பாடு மறுதொடக்கம் செய்யப்பட்டவுடன் மொழி மாறும் அண்மைக் காலத்தில் சேர்க்கப்பட்டது மிகவும் பிடித்தது மாநாடுகள் @@ -715,7 +711,7 @@ சந்தா எதுவும் தேர்ந்தெடுக்கப்படவில்லை %d தேர்ந்தெடுக்கப்பட்டது - %d தேர்ந்தெடுக்கப்பட்டது + %d தேர்ந்தெடுக்கப்பட்டன புதிய எப்போதும் புதுப்பிக்கவும் @@ -783,4 +779,9 @@ அடுத்த ச்ட்ரீம் மீண்டும் தரவு மற்றும் நினைவக பயன்பாட்டைக் குறைக்க, படங்களின் தகுதி மற்றும் படங்களை ஏற்ற வேண்டுமா என்பதைத் தேர்வுசெய்க. மாற்றங்கள் நினைவகம் மற்றும் ஆன்-வட்டு பட தற்காலிக சேமிப்பு இரண்டையும் அழிக்கின்றன- %s + YouTube தற்காலிக பிளேலிச்ட்டாக பகிரவும் + பிளேலிச்ட்கள் + தீவனக் குழுவைத் தேர்ந்தெடுக்கவும் + இதுவரை எந்த ஊட்டக் குழுவும் உருவாக்கப்படவில்லை + சேனல் குழு பக்கம் diff --git a/app/src/main/res/values-te/strings.xml b/app/src/main/res/values-te/strings.xml index 538ace593..a422dc996 100644 --- a/app/src/main/res/values-te/strings.xml +++ b/app/src/main/res/values-te/strings.xml @@ -77,9 +77,6 @@ వీడియో ఆడియో మళ్ళీ ప్రయత్నించు - కి - ఎం - బిలియన్ సభ్యులు లేరు %s సభ్యుడు @@ -363,7 +360,6 @@ reCAPTCHA సవాలు అభ్యర్థించబడింది ప్లేజాబితాను ఎంచుకోండి డాటాబేసుని ఎగుమతిచేయుము - యాప్ పునఃప్రారంభించబడిన తర్వాత భాష మారుతుంది ఛానెల్ వివరాలను చూపు కంటెంట్‌ని తెరిచేటప్పుడు డిఫాల్ట్ చర్య — %s ప్రాధాన్య \'తెరవండి\' చర్య diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index bcbbca0a4..c2cb4669e 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -165,9 +165,6 @@ วิดีโอ เสียง ลองอีกครั้ง - พัน - ล้าน - พันล้าน ไม่มีสมาชิกที่สมัครรับ %s บอกรับ diff --git a/app/src/main/res/values-ti/strings.xml b/app/src/main/res/values-ti/strings.xml index 252f13954..f25b86cc1 100644 --- a/app/src/main/res/values-ti/strings.xml +++ b/app/src/main/res/values-ti/strings.xml @@ -2,22 +2,22 @@ ሰረዝ ኣብ መርበብ-ሓበሬታ ክፉት - ውጽኢት ምርኣዩ ን፦ %s - መጀመርታ ምእንቲ ኽትጅምር ነቲ ዜጕልሕ መረጼን ጠውቆ። - ኣብ %1$s እተሓትመ - ወሓይዝ ዚጻወት ኣይተረኽበን። VLC፧ + ውጽኢት ናይ፦ %s + ንኽትጅምር ነቲ ምድላይ ምልክት ጠውቆ። + ዝተሓትመሉ ዕለት %1$s + ናይ ዥረት ተጻዋታይ ኣይተረኽበን። VLC ይውርድ፧ ሐራይ ቅጥዕታት \"%1$s\" ማለቱ ድዩ፧ ዚተሪየ ጋባራ ክፉቴ ምሲ - ፋይል ውሕጅ ኣራግፍ + ስትሪም ፋይል ኣውርድ ድለ ኣካፍሎ ኣራግፍ ኣካፍሎም ምሲ መስመር ኣይትጽንበሩ - ኣብ ሓፍ፡ሓፍ ቅዲ ክፉት + ኣብ ፖፕኣፕ ኣገባብ ክፉት ሰዓብካ\'ኪ ክቕይር ኣይከኣለን ምዝገብ ድምጺታት ኣብ ገለ ርዝነት የወግድ @@ -26,7 +26,7 @@ ሰዓበ ናይ ደገ ቪድዮ ተጠቐም መጻወቲ ምውራድ - ኣይትጽንበሩ + ምስዓብ ኣቋርጽ ነቲ ኣብ\'ቲ ምልክታ ዝተርኣየ ናይ ቪድዮ ምስሊ ካብ 16:9 ናብ 1:1 ርሕቐት ኣቀራርባ ቅረጽ ምስ Kodi ተጻወት ድምር ምስ @@ -43,11 +43,11 @@ ናይ ደገ ድምጺ መጻወቲ ተጠቐም Android ሕብሪ ናይቲ መተሓሳሰቢ ብመሰረት እቲ ኣብቲ ንእሽቶ ስእሊ ዘሎ ቀንዲ ሕብሪ ከም ዝጥዕሞ ግበር (እዚ ኣብ ኩሉ መሳርሒታት ከምዘይርከብ ኣስተውዕል) ትሑዝ ፖፕኣፕ ድንቀት - ነፍሲ ወከፍ መፍለጢ ተግባር ኣብ ታሕቲ ብምጥዋቕ ኣርትዖ። ኣብቲ ውህሉል መተሓሳሰቢ ንኽርአ ክሳብ ሰለስተ ካብኣቶም ምረጽ፡ ኣብ የማናይ ሸነኽ ዘሎ ሳጹናት ብምጥቃም + ነፍሲ ወከፍ መፍለጢ ተግባር ኣብ ታሕቲ ብምጥዋቕ ኣርትዖ። ኣብቲ ውህሉል መተሓሳሰቢ ንኽርአ ክሳብ ሰለስተ ካብኣቶም ምረጽ፡ ኣብ የማናይ ሸነኽ ዘሎ ሳጹናት ብምጥቃም። ፖፕኣፕ ትሑዝ ድንቀት ዝወረዱ ናይ ተንቃሳቀሴ-ምስሌ ፋይላት ኣብዚ ይኽዘኑ - + ነባሪ ቪድዮ ፎርማት ናይ እኽሊ ምስሊ ምስ ናይ 1:1 ርክባት ቀዳማይ ወሰን ተጠዋቃ ደገመ @@ -56,7 +56,7 @@ ናይ ድምጺ ፋይል ኣራግፍ ምረጽ ዝጎደለ ኮረ ኣፕፕ ኣውራድ፧ ነባሪ ቅርጺ ድምጺ - ምዝገባ + እትስዕቦም Kodi ሚድያ ማእኸል ቪድዮ ንምጽዋት ዝሕግዝ ኣማራጺ ኣብቲ ውሱን መፍለጢ ንምርኣይ እንተበዝሐ ሰለስተ ተግባራት ክትመርጽ ትኽእል ኢኻ! ተንቃሳቀሴ-ምስሌ ፋይል ኣራግፍ @@ -70,5 +70,28 @@ እወ ኣይፋልን ጸሊም - ኣርእስቲ - \ No newline at end of file + ቆርበት ኣርእስቲ + ለይታው ቆርበት + ናይ ፖፕኣፕ ባህርያት ዘክር + ናይ ፖፕኣፕ ዝነበሮ ቦታ ዘክር + ቅድሚ ምጽራይ ተራታት ናይ መረጋገጺ ሕተት + ርእይቶታት ኣርእዩ + ርእይቶታት ንምሕባእ እዚ ኣጥፍእ + ዋላ-ሓደ + ውራድ + ኣጻዋታይ + ትርኢት + ትሕዝቶ + ህያው + ርጋፍታት + ርጋፍታት + ኵሉ + መስመርት + ዝርዝር-ጸወታ + ቪዶ + ተጠቀምቲ + ፍጻመታት + ደርፍታት + ኣልበማት + ስነ-ጥበባውያን + diff --git a/app/src/main/res/values-tl/strings.xml b/app/src/main/res/values-tl/strings.xml index 02794a620..d41b317e8 100644 --- a/app/src/main/res/values-tl/strings.xml +++ b/app/src/main/res/values-tl/strings.xml @@ -66,5 +66,4 @@ \nBuksan ang \"%1$s\" sa ayos ng app kung gusto mong makita ito. Mga Artista Nakalutang - \ No newline at end of file diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 1c736bb99..c6a1c8557 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -88,9 +88,6 @@ Devre dışı Yorumunuz (İngilizce): Ayrıntılar: - b - M - M Bu izin, açılır pencere kipinde \naçmak için gereklidir reCAPTCHA formu @@ -164,9 +161,9 @@ Kurtarılamayan oynatıcı hatası oluştu Oynatıcı hatasından kurtarılıyor Ana sayfanın içeriği - Boş Sayfa - Kiosk Sayfası - Kanal Sayfası + Boş sayfa + Kiosk sayfası + Kanal sayfası Kanal seçin Henüz kanal aboneliği yok Kiosk seçin @@ -212,7 +209,7 @@ Bu, var olan kurulumunuzu geçersiz kılacaktır. Akış dosyasını indir Bilgi göster - İmlenen Çalma Listeleri + İmlenen Oynatma Listeleri Ekle Yeniden düzenlemek için sürükleyin Oluştur @@ -422,7 +419,6 @@ %s dinleyici %s dinleyici - Uygulama yeniden başlatıldıktan sonra dil değişecektir Hızlı ileri/geri atlama süresi PeerTube örnekleri Favori PeerTube örneklerinizi seçin @@ -823,4 +819,14 @@ \nBunu etkinleştirmek istiyor musunuz? İçe aktarılmakta olan dışa aktarımdaki ayarlar, NewPipe 0.27.0\'dan itibaren kullanımdan kaldırılan güvenlik açığı olan bir biçim kullanmaktadır. İçe aktarılmakta olan dışa aktarımın güvenilir bir kaynaktan alındığından emin olun ve gelecekte yalnızca NewPipe 0.27.0 veya daha yeni sürümlerden alınan dışa aktarımları kullanmayı tercih edin. Bu güvenlik açığı bulunan biçimdeki ayarları içe aktarma desteği yakında tamamen kaldırılacak ve ardından NewPipe\'ın eski sürümleri artık yeni sürümlerden dışa aktarılan ayarların içe aktarımını yapamayacaktır. ikincil + Oynatma Listeleri + YouTube geçici oynatma listesi olarak paylaş + Besleme kümesi seç + Kanal küme sayfası + Besleme kümesi oluşturulmadı + Beğeni + %1$s İle Ara + %1$s İle Ara (%2$s) + SoundCloud Top 50 sayfası kaldırıldı + SoundCloud, özgün Top 50 listesini artık yayınlamıyor. İlgili sekme ana sayfanızdan kaldırıldı. diff --git a/app/src/main/res/values-tzm/strings.xml b/app/src/main/res/values-tzm/strings.xml index 31d89bdd5..a62b37fa3 100644 --- a/app/src/main/res/values-tzm/strings.xml +++ b/app/src/main/res/values-tzm/strings.xml @@ -113,7 +113,6 @@ Kkes Senulfu Senti - ifḍ Als-arem Imesli Avidyu @@ -178,4 +177,4 @@ Ilbumen Tilgamin n tɣuri Taɣuri tawurmant - \ No newline at end of file + diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index c9fa9f25c..75498798a 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -65,9 +65,6 @@ Відео Аудіо Повторити спробу - тис - млн - млрд Почати Пауза Видалити @@ -218,8 +215,8 @@ Відтворювалося найбільше Вміст на головній сторінці Порожня сторінка - Кіоск-сторінка - Канал + Сторінка кіоску + Сторінка каналу Обрати канал Немає підписок на канали Обрати кіоск @@ -428,7 +425,6 @@ %s слухача %s слухачів - Мова зміниться після перезапуску застосунку Швидке перемотування Екземпляри PeerTube Виберіть сервер PeerTube @@ -842,4 +838,14 @@ \nВи впевнені, що хочете продовжити? Параметри експорту, який імпортується, використовують вразливий формат, який не підтримується з NewPipe 0.27.0. Переконайтеся, що експорт, який імпортується, походить із надійного джерела, і в майбутньому віддайте перевагу використанню лише експорту, отриманого з NewPipe 0.27.0 або новішої версії. Підтримку імпорту налаштувань у цьому вразливому форматі незабаром буде повністю припинено, і тоді старі версії NewPipe більше не зможуть імпортувати налаштування експорту з нових версій. вторинний + Поділитися як тимчасовим списком відтворення YouTube + Списки відтворення + Сторінка групи каналів + Виберіть групу каналів + Групу каналів ще не створено + Вподобання + Пошук %1$s + Пошук %1$s (%2$s) + Сторінку SoundCloud Top 50 видалено + SoundCloud припинив підтримку оригінальних чартів Топ-50. Відповідну вкладку видалено з вашої головної сторінки. diff --git a/app/src/main/res/values-und/strings.xml b/app/src/main/res/values-und/strings.xml index 94e4f9dd6..0bd3bf442 100644 --- a/app/src/main/res/values-und/strings.xml +++ b/app/src/main/res/values-und/strings.xml @@ -70,7 +70,6 @@ ویڈیو آڈیو فیر کرو - ہزار بݨاؤ بارے لائیسنس diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml index 3bfa5deff..bb95e2811 100644 --- a/app/src/main/res/values-ur/strings.xml +++ b/app/src/main/res/values-ur/strings.xml @@ -146,9 +146,6 @@ ویڈیو آڈیو دوبارہ کوشش کریں - ہزار - دہ لاکھ - ارب کوئی صارفین نہیں %s صارف @@ -417,7 +414,6 @@ %s سننے والا %s سننے والے - جب ایپ دوبارہ شروع ہو گی تو زبان تبدیل ہو جائے گی طے شدہ کیوسک آگے بھگانے /- پیچھے کرنے کی مدت پیر ٹیوب واقعات @@ -478,7 +474,7 @@ شفل دوہرائیں آپ کومپیکٹ نوٹیفکیشن میں زیادہ سے زیادہ تین ایکشن منتخب کرسکتے ہیں! - ذیل میں ہر اطلاع کی کارروائی پر ٹیپ کرکے اس میں ترمیم کریں۔ دائیں طرف کے چیک باکسز کا استعمال کرکے کمپیکٹ نوٹیفکیشن میں دکھائے جانے والے ان میں سے تین تک کا انتخاب کریں: + ذیل میں ہر اطلاع کی کارروائی پر ٹیپ کرکے اس میں ترمیم کریں۔ دائیں طرف کے چیک باکسز کا استعمال کرکے کمپیکٹ نوٹیفکیشن میں دکھائے جانے والے ان میں سے تین تک کا انتخاب کریں۔ پانچواں ایکشن بٹن چوتھا ایکشن بٹن تیسرا ایکشن بٹن @@ -555,4 +551,9 @@ مستقل تھمب نیل کو ان سیٹ کریں ہارڈ ویئر میڈیا بٹن کے واقعات کو نظر انداز کریں کار آمد، مثلاً، اگر آپ ہیڈسیٹ ٹوٹے ہوئے فزیکل بٹن کے ساتھ استعمال کر رہے ہیں + ذیل میں ہر اطلاعی کارروائی پر ٹیپ کرکے ترمیم کریں۔ پہلے تین ایکشن (پلے/پاؤز، پچھلی اور اگلی) سسٹم کے ذریعے سیٹ کیے گئے ہیں اور انہیں اپنی مرضی کے مطابق نہیں بنایا جا سکتا۔ + پروگریسو مواد پر لوڈ وقفہ کا سائز تبدیل کریں (فی الحال %s)۔ کم کیمیت ان کی ابتدائی لوڈنگ کو تیز کر سکتی ہے + پلے لسٹ + ہاں + نہیں diff --git a/app/src/main/res/values-v35/styles.xml b/app/src/main/res/values-v35/styles.xml new file mode 100644 index 000000000..beb16bcdf --- /dev/null +++ b/app/src/main/res/values-v35/styles.xml @@ -0,0 +1,27 @@ + + + + + + + + +