diff --git a/CREDITS.md b/CREDITS.md new file mode 100644 index 0000000000..97ca57edf8 --- /dev/null +++ b/CREDITS.md @@ -0,0 +1,13 @@ +# Credits / prior work + +This SmartTube integration is based on the **Voice Over Translation** ecosystem and Yandex browser VOT API behavior documented by these open-source projects: + +| Project | Author / org | Role | +|---------|----------------|------| +| [voice-over-translation](https://github.com/ilyhalight/voice-over-translation) | [ilyhalight](https://github.com/ilyhalight) | Browser extension; UX and API flow reference | +| [vot-cli](https://github.com/FOSWLY/vot-cli) | [FOSWLY](https://github.com/FOSWLY) | CLI client; protobuf / request patterns | +| [vot.js](https://github.com/FOSWLY/vot.js) | [FOSWLY](https://github.com/FOSWLY) | `fail-audio-js` fallback for `AUDIO_REQUESTED` | + +SmartTube TV code in `common/.../vot/` is a **new port** for Android TV (OkHttp + ExoPlayer), not a copy-paste of the extension source tree. + +Yandex VOT endpoints are **unofficial** and may change without notice. diff --git a/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/app/models/playback/controllers/VoiceTranslateController.java b/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/app/models/playback/controllers/VoiceTranslateController.java new file mode 100644 index 0000000000..6f66adc061 --- /dev/null +++ b/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/app/models/playback/controllers/VoiceTranslateController.java @@ -0,0 +1,592 @@ +package com.liskovsoft.smartyoutubetv2.common.app.models.playback.controllers; + +import com.liskovsoft.sharedutils.helpers.MessageHelpers; +import com.liskovsoft.sharedutils.mylogger.Log; +import com.liskovsoft.smartyoutubetv2.common.R; +import com.liskovsoft.mediaserviceinterfaces.data.MediaItemMetadata; +import com.liskovsoft.smartyoutubetv2.common.app.models.data.Video; +import com.liskovsoft.smartyoutubetv2.common.app.models.playback.BasePlayerController; +import com.liskovsoft.smartyoutubetv2.common.exoplayer.selector.FormatItem; +import com.liskovsoft.smartyoutubetv2.common.prefs.VotData; +import com.liskovsoft.smartyoutubetv2.common.utils.AppDialogUtil; +import com.liskovsoft.smartyoutubetv2.common.utils.Utils; +import com.liskovsoft.smartyoutubetv2.common.vot.TranslationAudioPlayer; +import com.liskovsoft.smartyoutubetv2.common.vot.VotAudioTrackHelper; +import com.liskovsoft.smartyoutubetv2.common.vot.VotAudioTrackHelper.TrackInfo; +import com.liskovsoft.smartyoutubetv2.common.vot.VotClient; +import com.liskovsoft.smartyoutubetv2.common.vot.VotProgress; + +import java.io.IOException; +import java.util.List; + +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.Disposable; + +/** + * Yandex voice-over translation (EN→RU) alongside the main player. + */ +public class VoiceTranslateController extends BasePlayerController { + private static final String TAG = VoiceTranslateController.class.getSimpleName(); + private static final int ACTION_VOICE_TRANSLATE = R.id.action_voice_translate; + + public static final int BTN_OFF = 0; + public static final int BTN_PENDING = 1; + public static final int BTN_ON = 2; + + private static final int STATE_OFF = 0; + private static final int STATE_PENDING = 1; + private static final int STATE_ACTIVE = 2; + + private static final long SYNC_INTERVAL_MS = 1000; + private static final long SYNC_THRESHOLD_MS = 800; + private static final long AUTO_TRANSLATE_RETRY_MS = 1000; + private static final int AUTO_TRANSLATE_MAX_RETRIES = 20; + + private VotData mVotData; + private VotClient mVotClient; + private TranslationAudioPlayer mTranslationPlayer; + private Disposable mTranslationDisposable; + private float mSavedMainVolume = 1f; + private FormatItem mSavedAudioFormat; + private boolean mUserArmed; + private boolean mArmed; + private int mState = STATE_OFF; + private int mPendingEtaSec; + private boolean mPendingToastShown; + private String mPendingVideoUrl; + private String mCurrentVideoId; + private int mAutoTranslateRetryCount; + + private final Runnable mSyncRunnable = new Runnable() { + @Override + public void run() { + if (mState == STATE_ACTIVE) { + syncTranslationPositionIfNeeded(); + Utils.postDelayed(mSyncRunnable, SYNC_INTERVAL_MS); + } + } + }; + + private final Runnable mAutoTranslateRetryRunnable = new Runnable() { + @Override + public void run() { + tryApplyAutoTranslate(false); + } + }; + + public VoiceTranslateController() { + } + + private VotData votData() { + if (mVotData == null) { + mVotData = VotData.instance(getContext()); + } + return mVotData; + } + + private VotClient votClient() { + if (mVotClient == null) { + mVotClient = new VotClient(getContext()); + } + return mVotClient; + } + + @Override + public void onNewVideo(Video item) { + Utils.removeCallbacks(mAutoTranslateRetryRunnable); + mAutoTranslateRetryCount = 0; + cancelTranslationJob(); + releaseTranslationPlayer(); + restoreMainVolume(); + restoreSavedAudioFormat(); + mPendingToastShown = false; + mPendingVideoUrl = null; + mCurrentVideoId = item != null ? item.videoId : null; + + if (mUserArmed) { + mArmed = true; + setState(STATE_PENDING); + } else { + mArmed = false; + setState(STATE_OFF); + } + } + + @Override + public void onVideoLoaded(Video item) { + tryApplyAutoTranslate(false); + } + + @Override + public void onMetadata(MediaItemMetadata metadata) { + tryApplyAutoTranslate(false); + } + + @Override + public void onTrackChanged(FormatItem track) { + if (track != null && track.getType() == FormatItem.TYPE_AUDIO) { + tryApplyAutoTranslate(true); + } + } + + @Override + public void onPlay() { + if (mState == STATE_ACTIVE && mTranslationPlayer != null) { + mTranslationPlayer.resume(); + syncTranslationPositionIfNeeded(); + } + } + + @Override + public void onPause() { + if (mState == STATE_ACTIVE && mTranslationPlayer != null) { + mTranslationPlayer.pause(); + } + } + + @Override + public void onSeekEnd() { + if (mState == STATE_ACTIVE && mTranslationPlayer != null && mTranslationPlayer.isReady()) { + mTranslationPlayer.seekTo(getPlayer().getPositionMs()); + } + } + + @Override + public void onSpeedChanged(float speed) { + if (mState == STATE_ACTIVE && mTranslationPlayer != null) { + mTranslationPlayer.setPlaybackSpeed(speed); + } + } + + @Override + public void onEngineReleased() { + disarm(); + } + + @Override + public void onButtonClicked(int buttonId, int buttonState) { + if (buttonId != ACTION_VOICE_TRANSLATE) { + return; + } + if (buttonState == BTN_OFF) { + armAndStart(); + } else { + disarm(); + } + } + + @Override + public void onButtonLongClicked(int buttonId, int buttonState) { + if (buttonId == ACTION_VOICE_TRANSLATE) { + AppDialogUtil.showVotMixDialog(getContext(), () -> tryApplyAutoTranslate(false)); + } + } + + private void armAndStart() { + if (votData().isPreferYoutubeAutoDub()) { + MessageHelpers.showMessage(getContext(), R.string.vot_disable_google_for_yandex); + return; + } + TrackInfo info = resolveAudioInfo(); + if (VotAudioTrackHelper.isRussianOriginal(info)) { + MessageHelpers.showMessage(getContext(), R.string.vot_already_russian); + return; + } + mUserArmed = true; + mArmed = true; + startYandexTranslation(); + } + + private void tryApplyAutoTranslate(boolean fromTrackChange) { + if (getPlayer() == null || getPlayer().getVideo() == null) { + return; + } + String videoId = getPlayer().getVideo().videoId; + if (videoId != null && !videoId.equals(mCurrentVideoId)) { + mCurrentVideoId = videoId; + mAutoTranslateRetryCount = 0; + } + + boolean autoEnabled = votData().isAutoTranslateEnabled(); + if (!autoEnabled && !mUserArmed) { + return; + } + + if (mState == STATE_ACTIVE) { + return; + } + + if (votData().isPreferYoutubeAutoDub()) { + if (tryApplyYoutubeAutoDub(autoEnabled)) { + return; + } + } + + TrackInfo info = resolveAudioInfo(); + + if (info.langCode == null && info.acont == null) { + List formats = getAudioFormats(); + if (autoEnabled && VotAudioTrackHelper.shouldAutoStartLikeManual(info, formats)) { + Utils.removeCallbacks(mAutoTranslateRetryRunnable); + mAutoTranslateRetryCount = 0; + Log.d(TAG, "auto-start (legacy/metadata-poor) video=%s tracks=%s", + videoId, VotAudioTrackHelper.formatTracksForLog(formats)); + if (!mArmed || mState == STATE_OFF) { + mArmed = true; + startYandexTranslation(); + } + return; + } + if ((autoEnabled || mUserArmed) && mAutoTranslateRetryCount < AUTO_TRANSLATE_MAX_RETRIES) { + mAutoTranslateRetryCount++; + Utils.removeCallbacks(mAutoTranslateRetryRunnable); + Utils.postDelayed(mAutoTranslateRetryRunnable, AUTO_TRANSLATE_RETRY_MS); + return; + } + if (autoEnabled && !VotAudioTrackHelper.isLikelyRussianContent(info, formats) + && (VotAudioTrackHelper.canStartLikeManualButton(info) + || VotAudioTrackHelper.isMetadataPoorNonRussianAudio(formats))) { + Log.d(TAG, "auto-start (late metadata) video=%s tracks=%s", + videoId, VotAudioTrackHelper.formatTracksForLog(formats)); + if (!mArmed || mState == STATE_OFF) { + mArmed = true; + startYandexTranslation(); + } + return; + } + if (autoEnabled) { + Log.d(TAG, "auto skipped (no lang) video=%s tracks=%s", + videoId, VotAudioTrackHelper.formatTracksForLog(formats)); + } + return; + } + + Utils.removeCallbacks(mAutoTranslateRetryRunnable); + mAutoTranslateRetryCount = 0; + + if (VotAudioTrackHelper.isRussianOriginal(info)) { + if (mUserArmed) { + disarmWithMessage(R.string.vot_skip_russian); + mUserArmed = false; + } else if (autoEnabled) { + if (mArmed || mState != STATE_OFF) { + disarmQuiet(); + } + MessageHelpers.showMessage(getContext(), R.string.vot_skip_russian); + } + return; + } + + if (!autoEnabled && !mUserArmed) { + return; + } + + List formats = getAudioFormats(); + if (VotAudioTrackHelper.shouldAutoStartLikeManual(info, formats)) { + if (!mArmed || (mState == STATE_OFF && mTranslationDisposable == null)) { + mArmed = true; + startYandexTranslation(); + } + } else if (autoEnabled) { + Log.d(TAG, "auto skipped video=%s track=%s legacy=%s all=%s", + videoId, info.rawLabel, + VotAudioTrackHelper.isLegacyEnglishStream(info, formats), + VotAudioTrackHelper.formatTracksForLog(formats)); + } + } + + /** @return true if YouTube dub was applied and Yandex should not run */ + private boolean tryApplyYoutubeAutoDub(boolean showToast) { + FormatItem dub = VotAudioTrackHelper.findYoutubeRussianAutoDub(getAudioFormats()); + if (dub == null) { + return false; + } + saveCurrentAudioFormatBeforeSwitch(dub); + getPlayer().setFormat(dub); + mArmed = false; + mUserArmed = false; + cancelTranslationJob(); + releaseTranslationPlayer(); + restoreMainVolume(); + setState(STATE_OFF); + if (showToast) { + MessageHelpers.showMessage(getContext(), R.string.vot_using_youtube_dub); + } + return true; + } + + private void startYandexTranslation() { + if (getPlayer() == null || getPlayer().getVideo() == null) { + MessageHelpers.showMessage(getContext(), R.string.vot_error_no_video); + return; + } + + ensureOriginalAudioForYandex(); + + TrackInfo info = resolveAudioInfo(); + if (VotAudioTrackHelper.isRussianOriginal(info)) { + disarmWithMessage(R.string.vot_skip_russian); + return; + } + + String videoUrl = getPlayer().getVideo().videoId != null + ? "https://www.youtube.com/watch?v=" + getPlayer().getVideo().videoId + : null; + if (videoUrl == null) { + MessageHelpers.showMessage(getContext(), R.string.vot_error_no_video); + return; + } + + if (mTranslationDisposable != null && !mTranslationDisposable.isDisposed() + && videoUrl.equals(mPendingVideoUrl)) { + return; + } + + cancelTranslationJob(); + mPendingToastShown = false; + mPendingVideoUrl = videoUrl; + setState(STATE_PENDING); + + long durationSec = Math.max(1, getPlayer().getDurationMs() / 1000); + mTranslationDisposable = votClient().observeTranslation(videoUrl, durationSec) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + this::onVotProgress, + this::onVotError + ); + } + + private void ensureOriginalAudioForYandex() { + if (getPlayer() == null) { + return; + } + TrackInfo current = resolveAudioInfo(); + if (VotAudioTrackHelper.isOriginalTrack(current) && !VotAudioTrackHelper.isYoutubeAutoDub(current)) { + return; + } + FormatItem original = VotAudioTrackHelper.findBestOriginalForYandex(getAudioFormats()); + if (original == null) { + return; + } + FormatItem active = getPlayer().getAudioFormat(); + if (!VotAudioTrackHelper.isSameFormat(active, original)) { + saveCurrentAudioFormatBeforeSwitch(original); + getPlayer().setFormat(original); + } + } + + private void saveCurrentAudioFormatBeforeSwitch(FormatItem target) { + if (getPlayer() == null || target == null || mSavedAudioFormat != null) { + return; + } + FormatItem current = getPlayer().getAudioFormat(); + if (current != null && !VotAudioTrackHelper.isSameFormat(current, target)) { + mSavedAudioFormat = current; + } + } + + private void restoreSavedAudioFormat() { + if (mSavedAudioFormat != null && getPlayer() != null) { + getPlayer().setFormat(mSavedAudioFormat); + mSavedAudioFormat = null; + } + } + + private TrackInfo resolveAudioInfo() { + if (getPlayer() == null) { + return VotAudioTrackHelper.from(null); + } + return VotAudioTrackHelper.resolveCurrent(getPlayer().getAudioFormat(), getAudioFormats()); + } + + private List getAudioFormats() { + return getPlayer() != null ? getPlayer().getAudioFormats() : null; + } + + private void onVotProgress(VotProgress progress) { + if (getPlayer() == null || getPlayer().getVideo() == null) { + return; + } + String currentUrl = "https://www.youtube.com/watch?v=" + getPlayer().getVideo().videoId; + if (mPendingVideoUrl != null && !mPendingVideoUrl.equals(currentUrl)) { + return; + } + if (!mArmed) { + return; + } + + switch (progress.type) { + case VotProgress.TYPE_WAITING: + mPendingEtaSec = progress.remainingTimeSec; + setState(STATE_PENDING); + if (!mPendingToastShown) { + mPendingToastShown = true; + if (progress.remainingTimeSec > 0) { + int min = Math.max(1, (progress.remainingTimeSec + 59) / 60); + MessageHelpers.showMessage(getContext(), + getContext().getString(R.string.vot_pending_eta, min)); + } else { + MessageHelpers.showMessage(getContext(), R.string.vot_pending_long); + } + } + break; + case VotProgress.TYPE_READY: + if (progress.audioUrl != null) { + playTranslation(progress.audioUrl); + MessageHelpers.showMessage(getContext(), R.string.vot_enabled); + } + break; + case VotProgress.TYPE_FAILED: + handleTranslationError(progress.message); + break; + } + } + + private void onVotError(Throwable e) { + String msg = e.getMessage(); + if (e instanceof IOException) { + handleTranslationError(msg); + } else { + handleTranslationError(msg != null ? msg : getContext().getString(R.string.vot_error_generic)); + } + } + + private void playTranslation(String audioUrl) { + if (getPlayer() == null) { + return; + } + releaseTranslationPlayer(); + mTranslationPlayer = new TranslationAudioPlayer(getContext()); + mTranslationPlayer.setOnReadyListener(() -> { + if (getPlayer() != null && mTranslationPlayer != null) { + mTranslationPlayer.seekTo(getPlayer().getPositionMs()); + } + }); + float speed = getPlayer().getSpeed(); + mTranslationPlayer.play( + audioUrl, + getPlayer().getPositionMs(), + votData().getTranslationVolumeMultiplier(), + speed > 0 ? speed : 1f + ); + duckMainAudio(); + setState(STATE_ACTIVE); + Utils.removeCallbacks(mSyncRunnable); + Utils.postDelayed(mSyncRunnable, SYNC_INTERVAL_MS); + } + + private void syncTranslationPositionIfNeeded() { + if (mTranslationPlayer == null || getPlayer() == null || !mTranslationPlayer.isReady()) { + return; + } + long mainPos = getPlayer().getPositionMs(); + long transPos = mTranslationPlayer.getPositionMs(); + if (Math.abs(mainPos - transPos) > SYNC_THRESHOLD_MS) { + mTranslationPlayer.seekTo(mainPos); + } + } + + private void duckMainAudio() { + if (getPlayer() == null) { + return; + } + mSavedMainVolume = getPlayer().getVolume(); + getPlayer().setVolume(votData().getOriginalVolumeMultiplier()); + } + + private void restoreMainVolume() { + if (getPlayer() != null) { + getPlayer().setVolume(mSavedMainVolume); + } + } + + private void cancelTranslationJob() { + if (mTranslationDisposable != null && !mTranslationDisposable.isDisposed()) { + mTranslationDisposable.dispose(); + } + mTranslationDisposable = null; + } + + private void releaseTranslationPlayer() { + Utils.removeCallbacks(mSyncRunnable); + if (mTranslationPlayer != null) { + mTranslationPlayer.release(); + mTranslationPlayer = null; + } + } + + private void disarm() { + mUserArmed = false; + mArmed = false; + Utils.removeCallbacks(mAutoTranslateRetryRunnable); + cancelTranslationJob(); + releaseTranslationPlayer(); + restoreMainVolume(); + restoreSavedAudioFormat(); + setState(STATE_OFF); + mPendingVideoUrl = null; + } + + private void disarmQuiet() { + mArmed = false; + cancelTranslationJob(); + releaseTranslationPlayer(); + restoreMainVolume(); + setState(STATE_OFF); + mPendingVideoUrl = null; + } + + private void disarmWithMessage(int msgResId) { + mUserArmed = false; + mArmed = false; + Utils.removeCallbacks(mAutoTranslateRetryRunnable); + cancelTranslationJob(); + releaseTranslationPlayer(); + restoreMainVolume(); + restoreSavedAudioFormat(); + setState(STATE_OFF); + mPendingVideoUrl = null; + MessageHelpers.showMessage(getContext(), msgResId); + } + + private void handleTranslationError(String message) { + Log.e(TAG, "Translation error: %s", message); + if (message != null && message.contains("auth required")) { + MessageHelpers.showMessage(getContext(), R.string.vot_error_auth_required); + } else { + MessageHelpers.showMessage(getContext(), R.string.vot_error_generic); + } + if (mArmed) { + setState(STATE_PENDING); + } else { + setState(STATE_OFF); + } + } + + private void setState(int state) { + mState = state; + int btnIndex; + switch (state) { + case STATE_PENDING: + btnIndex = BTN_PENDING; + break; + case STATE_ACTIVE: + btnIndex = BTN_ON; + break; + default: + btnIndex = BTN_OFF; + break; + } + updateVoiceButton(btnIndex); + } + + private void updateVoiceButton(int index) { + if (getPlayer() == null) { + return; + } + getPlayer().setButtonState(ACTION_VOICE_TRANSLATE, index); + if (index == BTN_PENDING) { + getPlayer().updateVoiceTranslatePendingEta(mPendingEtaSec); + } + } +} diff --git a/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/app/models/playback/manager/PlayerUI.java b/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/app/models/playback/manager/PlayerUI.java index d21fd98e45..196052bf3c 100644 --- a/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/app/models/playback/manager/PlayerUI.java +++ b/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/app/models/playback/manager/PlayerUI.java @@ -28,6 +28,7 @@ public interface PlayerUI { boolean isControlsShown(); int getButtonState(int buttonId); void setButtonState(int buttonId, int buttonState); + void updateVoiceTranslatePendingEta(int remainingTimeSec); void setChannelIcon(String iconUrl); void setSeekPreviewTitle(String title); void setNextTitle(Video nextVideo); diff --git a/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/app/presenters/PlaybackPresenter.java b/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/app/presenters/PlaybackPresenter.java index 3514d8fbb6..e1ad7f557c 100644 --- a/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/app/presenters/PlaybackPresenter.java +++ b/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/app/presenters/PlaybackPresenter.java @@ -19,6 +19,7 @@ import com.liskovsoft.smartyoutubetv2.common.app.models.playback.controllers.SuggestionsController; import com.liskovsoft.smartyoutubetv2.common.app.models.playback.controllers.VideoLoaderController; import com.liskovsoft.smartyoutubetv2.common.app.models.playback.controllers.VideoStateController; +import com.liskovsoft.smartyoutubetv2.common.app.models.playback.controllers.VoiceTranslateController; import com.liskovsoft.smartyoutubetv2.common.app.models.playback.listener.PlayerEventListener; import com.liskovsoft.smartyoutubetv2.common.app.models.playback.listener.ViewEventListener; import com.liskovsoft.smartyoutubetv2.common.app.presenters.base.BasePresenter; @@ -66,6 +67,7 @@ private PlaybackPresenter(Context context) { mEventListeners.add(new HQDialogController()); mEventListeners.add(new ChatController()); mEventListeners.add(new CommentsController()); + mEventListeners.add(new VoiceTranslateController()); } public static PlaybackPresenter instance(Context context) { diff --git a/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/app/presenters/settings/PlayerSettingsPresenter.java b/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/app/presenters/settings/PlayerSettingsPresenter.java index e2dce0dba8..82001a814a 100644 --- a/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/app/presenters/settings/PlayerSettingsPresenter.java +++ b/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/app/presenters/settings/PlayerSettingsPresenter.java @@ -18,7 +18,9 @@ import com.liskovsoft.smartyoutubetv2.common.prefs.PlayerData; import com.liskovsoft.smartyoutubetv2.common.prefs.PlayerTweaksData; import com.liskovsoft.smartyoutubetv2.common.prefs.SearchData; +import com.liskovsoft.smartyoutubetv2.common.prefs.VotData; import com.liskovsoft.smartyoutubetv2.common.utils.AppDialogUtil; +import com.liskovsoft.smartyoutubetv2.common.utils.VotTokenEditDialog; import com.liskovsoft.youtubeapi.service.internal.MediaServiceData; import java.util.ArrayList; @@ -31,6 +33,7 @@ public class PlayerSettingsPresenter extends BasePresenter { private final GeneralData mGeneralData; private final SidebarService mSidebarService; private final MediaServiceData mMediaServiceData; + private final VotData mVotData; private boolean mRestartApp; private final Runnable mOnFinish = () -> { if (mRestartApp) { @@ -47,6 +50,7 @@ private PlayerSettingsPresenter(Context context) { mGeneralData = GeneralData.instance(context); mSidebarService = SidebarService.instance(context); mMediaServiceData = MediaServiceData.instance(); + mVotData = VotData.instance(context); } public static PlayerSettingsPresenter instance(Context context) { @@ -59,6 +63,7 @@ public void show() { appendPlaybackModeCategory(settingsPresenter); appendVideoPresetsCategory(settingsPresenter); appendPlayerButtonsCategory(settingsPresenter); + appendVotCategory(settingsPresenter); appendNetworkEngineCategory(settingsPresenter); appendVideoBufferCategory(settingsPresenter); appendVideoZoomCategory(settingsPresenter); @@ -197,12 +202,72 @@ private void appendScreenOffTimeoutCategory(AppDialogPresenter settingsPresenter settingsPresenter.appendRadioCategory(category.title, category.options); } + private void appendVotCategory(AppDialogPresenter settingsPresenter) { + List options = new ArrayList<>(); + + String status = mVotData.hasOAuthToken() + ? getContext().getString(R.string.vot_token_status_set, mVotData.getTokenPreview()) + : getContext().getString(R.string.vot_token_status_empty); + + options.add(UiOptionItem.from(status, optionItem -> showVotTokenDialog())); + + options.add(UiOptionItem.from(getContext().getString(R.string.vot_edit_token), optionItem -> showVotTokenDialog())); + + options.add(UiOptionItem.from(getContext().getString(R.string.vot_paste_clipboard_action), + optionItem -> VotTokenEditDialog.pasteFromClipboard(getContext(), token -> { + if (token.isEmpty()) { + mVotData.clearOAuthToken(); + MessageHelpers.showMessage(getContext(), R.string.vot_token_cleared); + } else { + mVotData.setOAuthToken(token); + MessageHelpers.showMessage(getContext(), R.string.vot_token_saved); + } + }))); + + settingsPresenter.appendStringsCategory(getContext().getString(R.string.vot_settings_category), options); + + List votToggles = new ArrayList<>(); + votToggles.add(UiOptionItem.from( + getContext().getString(R.string.vot_lively_voice), + getContext().getString(R.string.vot_lively_voice_desc), + optionItem -> { + if (!mVotData.hasOAuthToken()) { + MessageHelpers.showMessage(getContext(), R.string.vot_error_auth_required); + return; + } + mVotData.setLivelyVoiceEnabled(optionItem.isSelected()); + }, + mVotData.isLivelyVoiceEnabled())); + settingsPresenter.appendCheckedCategory(getContext().getString(R.string.vot_lively_voice), votToggles); + + settingsPresenter.appendRadioCategory( + getContext().getString(R.string.vot_original_volume), + AppDialogUtil.createVotOriginalVolumeCategory(getContext()).options); + settingsPresenter.appendRadioCategory( + getContext().getString(R.string.vot_translation_volume), + AppDialogUtil.createVotTranslationVolumeCategory(getContext()).options); + } + + private void showVotTokenDialog() { + VotTokenEditDialog.show(getContext(), mVotData.getOAuthToken(), token -> { + if (token.isEmpty()) { + mVotData.clearOAuthToken(); + mVotData.setLivelyVoiceEnabled(false); + MessageHelpers.showMessage(getContext(), R.string.vot_token_cleared); + } else { + mVotData.setOAuthToken(token); + MessageHelpers.showMessage(getContext(), R.string.vot_token_saved); + } + }); + } + private void appendPlayerButtonsCategory(AppDialogPresenter settingsPresenter) { List options = new ArrayList<>(); for (int[] pair : new int[][] { {R.string.auto_frame_rate, PlayerTweaksData.PLAYER_BUTTON_AFR}, {R.string.action_sound_off, PlayerTweaksData.PLAYER_BUTTON_SOUND_OFF}, + {R.string.action_voice_translate, PlayerTweaksData.PLAYER_BUTTON_VOICE_TRANSLATE}, {R.string.video_rotate, PlayerTweaksData.PLAYER_BUTTON_VIDEO_ROTATE}, {R.string.video_flip, PlayerTweaksData.PLAYER_BUTTON_VIDEO_FLIP}, {R.string.open_chat, PlayerTweaksData.PLAYER_BUTTON_CHAT}, diff --git a/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/prefs/PlayerTweaksData.java b/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/prefs/PlayerTweaksData.java index 84989c36f7..bcbbe5255a 100644 --- a/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/prefs/PlayerTweaksData.java +++ b/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/prefs/PlayerTweaksData.java @@ -42,11 +42,12 @@ public class PlayerTweaksData implements ProfileChangeListener { public static final int PLAYER_BUTTON_SOUND_OFF = 1 << 25; public static final int PLAYER_BUTTON_AFR = 1 << 26; public static final int PLAYER_BUTTON_VIDEO_FLIP = 1 << 27; + public static final int PLAYER_BUTTON_VOICE_TRANSLATE = 1 << 28; public static final int PLAYER_BUTTON_DEFAULT = PLAYER_BUTTON_SEARCH | PLAYER_BUTTON_PIP | PLAYER_BUTTON_SCREEN_DIMMING | PLAYER_BUTTON_VIDEO_SPEED | PLAYER_BUTTON_VIDEO_STATS | PLAYER_BUTTON_OPEN_CHANNEL | PLAYER_BUTTON_SUBTITLES | PLAYER_BUTTON_SUBSCRIBE | PLAYER_BUTTON_LIKE | PLAYER_BUTTON_DISLIKE | PLAYER_BUTTON_ADD_TO_PLAYLIST | PLAYER_BUTTON_PLAY_PAUSE | PLAYER_BUTTON_REPEAT_MODE | PLAYER_BUTTON_NEXT | PLAYER_BUTTON_PREVIOUS | PLAYER_BUTTON_HIGH_QUALITY | - PLAYER_BUTTON_VIDEO_INFO | PLAYER_BUTTON_CHAT; + PLAYER_BUTTON_VIDEO_INFO | PLAYER_BUTTON_CHAT | PLAYER_BUTTON_VOICE_TRANSLATE; public static final int DNS_TYPE_SYSTEM = GlobalPreferences.DNS_TYPE_SYSTEM; public static final int DNS_TYPE_IPV4 = GlobalPreferences.DNS_TYPE_IPV4; public static final int DNS_TYPE_GOOGLE = GlobalPreferences.DNS_TYPE_GOOGLE; diff --git a/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/prefs/VotData.java b/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/prefs/VotData.java new file mode 100644 index 0000000000..4eac5351e8 --- /dev/null +++ b/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/prefs/VotData.java @@ -0,0 +1,121 @@ +package com.liskovsoft.smartyoutubetv2.common.prefs; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.text.TextUtils; + +import com.liskovsoft.sharedutils.prefs.SharedPreferencesBase; + +public class VotData extends SharedPreferencesBase { + private static final String PREFS_NAME = "vot_data"; + private static final String OAUTH_TOKEN = "yandex_oauth_token"; + private static final String LIVELY_VOICE = "use_lively_voice"; + private static final String ORIGINAL_VOLUME_PERCENT = "original_volume_percent"; + private static final String TRANSLATION_VOLUME_PERCENT = "translation_volume_percent"; + private static final String AUTO_TRANSLATE = "auto_translate_enabled"; + private static final String PREFER_YOUTUBE_AUTO_DUB = "prefer_youtube_auto_dub"; + private static final int DEFAULT_ORIGINAL_VOLUME_PERCENT = 15; + private static final int DEFAULT_TRANSLATION_VOLUME_PERCENT = 100; + + @SuppressLint("StaticFieldLeak") + private static VotData sInstance; + + private VotData(Context context) { + super(context.getApplicationContext(), PREFS_NAME); + } + + public static VotData instance(Context context) { + if (sInstance == null) { + sInstance = new VotData(context); + } + return sInstance; + } + + public String getOAuthToken() { + return getString(OAUTH_TOKEN, ""); + } + + public void setOAuthToken(String token) { + putString(OAUTH_TOKEN, normalizeToken(token)); + } + + public void clearOAuthToken() { + putString(OAUTH_TOKEN, ""); + } + + public boolean hasOAuthToken() { + return !TextUtils.isEmpty(getOAuthToken()); + } + + public boolean isLivelyVoiceEnabled() { + return getBoolean(LIVELY_VOICE, false) && hasOAuthToken(); + } + + public void setLivelyVoiceEnabled(boolean enabled) { + putBoolean(LIVELY_VOICE, enabled); + } + + /** YouTube/original track level while translation plays (0–100%). */ + public int getOriginalVolumePercent() { + return clampPercent(getInt(ORIGINAL_VOLUME_PERCENT, DEFAULT_ORIGINAL_VOLUME_PERCENT)); + } + + public void setOriginalVolumePercent(int percent) { + putInt(ORIGINAL_VOLUME_PERCENT, clampPercent(percent)); + } + + /** Russian voice-over level (0–100%). */ + public int getTranslationVolumePercent() { + return clampPercent(getInt(TRANSLATION_VOLUME_PERCENT, DEFAULT_TRANSLATION_VOLUME_PERCENT)); + } + + public void setTranslationVolumePercent(int percent) { + putInt(TRANSLATION_VOLUME_PERCENT, clampPercent(percent)); + } + + public boolean isAutoTranslateEnabled() { + return getBoolean(AUTO_TRANSLATE, false); + } + + public void setAutoTranslateEnabled(boolean enabled) { + putBoolean(AUTO_TRANSLATE, enabled); + } + + public boolean isPreferYoutubeAutoDub() { + return getBoolean(PREFER_YOUTUBE_AUTO_DUB, false); + } + + public void setPreferYoutubeAutoDub(boolean enabled) { + putBoolean(PREFER_YOUTUBE_AUTO_DUB, enabled); + } + + public float getOriginalVolumeMultiplier() { + return getOriginalVolumePercent() / 100f; + } + + public float getTranslationVolumeMultiplier() { + return getTranslationVolumePercent() / 100f; + } + + public String getTokenPreview() { + String token = getOAuthToken(); + if (TextUtils.isEmpty(token)) { + return ""; + } + if (token.length() <= 12) { + return token.substring(0, 4) + "…"; + } + return token.substring(0, 6) + "…" + token.substring(token.length() - 4); + } + + private static String normalizeToken(String token) { + if (token == null) { + return ""; + } + return token.trim().replaceAll("\\s+", ""); + } + + private static int clampPercent(int percent) { + return Math.max(0, Math.min(100, percent)); + } +} diff --git a/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/utils/AppDialogUtil.java b/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/utils/AppDialogUtil.java index 44f51140ff..427c734d01 100644 --- a/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/utils/AppDialogUtil.java +++ b/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/utils/AppDialogUtil.java @@ -43,6 +43,7 @@ import com.liskovsoft.smartyoutubetv2.common.prefs.GeneralData; import com.liskovsoft.smartyoutubetv2.common.prefs.PlayerData; import com.liskovsoft.smartyoutubetv2.common.prefs.PlayerTweaksData; +import com.liskovsoft.smartyoutubetv2.common.prefs.VotData; import com.liskovsoft.youtubeapi.service.YouTubeMediaItemService; import com.liskovsoft.youtubeapi.playlist.impl.YouTubePlaylistInfo; @@ -79,6 +80,8 @@ public class AppDialogUtil { private static final int PLAYER_ENGINE_ID = 147; private static final int IGNORE_DURATION_ID = 148; private static final int SLEEP_TIMER_ID = 149; + private static final int VOT_ORIGINAL_VOLUME_ID = 150; + private static final int VOT_TRANSLATION_VOLUME_ID = 151; private static final int SUBTITLE_STYLES_ID = 45; private static final int SUBTITLE_SIZE_ID = 46; private static final int SUBTITLE_POSITION_ID = 47; @@ -487,6 +490,78 @@ public static OptionCategory createAudioVolumeCategory(Context context, Runnable return OptionCategory.from(AUDIO_VOLUME_ID, OptionCategory.TYPE_RADIO_LIST, title, options); } + public static OptionCategory createVotOriginalVolumeCategory(Context context) { + return createVotOriginalVolumeCategory(context, () -> {}); + } + + public static OptionCategory createVotOriginalVolumeCategory(Context context, Runnable onSetCallback) { + VotData votData = VotData.instance(context); + String title = context.getString(R.string.vot_original_volume); + List options = new ArrayList<>(); + + for (int percent : Helpers.range(0, 100, 5)) { + options.add(UiOptionItem.from(String.format("%s%%", percent), + optionItem -> { + votData.setOriginalVolumePercent(percent); + onSetCallback.run(); + }, + percent == votData.getOriginalVolumePercent())); + } + + return OptionCategory.from(VOT_ORIGINAL_VOLUME_ID, OptionCategory.TYPE_RADIO_LIST, title, options); + } + + public static OptionCategory createVotTranslationVolumeCategory(Context context) { + return createVotTranslationVolumeCategory(context, () -> {}); + } + + public static OptionCategory createVotTranslationVolumeCategory(Context context, Runnable onSetCallback) { + VotData votData = VotData.instance(context); + String title = context.getString(R.string.vot_translation_volume); + List options = new ArrayList<>(); + + for (int percent : Helpers.range(0, 100, 5)) { + options.add(UiOptionItem.from(String.format("%s%%", percent), + optionItem -> { + votData.setTranslationVolumePercent(percent); + onSetCallback.run(); + }, + percent == votData.getTranslationVolumePercent())); + } + + return OptionCategory.from(VOT_TRANSLATION_VOLUME_ID, OptionCategory.TYPE_RADIO_LIST, title, options); + } + + public static void showVotMixDialog(Context context, Runnable onSetCallback) { + Runnable callback = onSetCallback != null ? onSetCallback : () -> {}; + VotData votData = VotData.instance(context); + AppDialogPresenter presenter = AppDialogPresenter.instance(context); + List toggles = new ArrayList<>(); + toggles.add(UiOptionItem.from( + context.getString(R.string.vot_auto_translate), + context.getString(R.string.vot_auto_translate_desc), + optionItem -> { + votData.setAutoTranslateEnabled(optionItem.isSelected()); + callback.run(); + }, + votData.isAutoTranslateEnabled())); + toggles.add(UiOptionItem.from( + context.getString(R.string.vot_prefer_youtube_dub), + context.getString(R.string.vot_prefer_youtube_dub_desc), + optionItem -> { + votData.setPreferYoutubeAutoDub(optionItem.isSelected()); + callback.run(); + }, + votData.isPreferYoutubeAutoDub())); + OptionCategory original = createVotOriginalVolumeCategory(context, callback); + OptionCategory translation = createVotTranslationVolumeCategory(context, callback); + String title = context.getString(R.string.vot_mix_menu_title); + presenter.appendCheckedCategory(context.getString(R.string.vot_mix_options), toggles); + presenter.appendRadioCategory(original.title, original.options); + presenter.appendRadioCategory(translation.title, translation.options); + presenter.showDialog(title); + } + public static OptionCategory createPitchEffectCategory(Context context) { String title = context.getString(R.string.pitch_effect); diff --git a/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/utils/VotTokenEditDialog.java b/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/utils/VotTokenEditDialog.java new file mode 100644 index 0000000000..d7a218ae5f --- /dev/null +++ b/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/utils/VotTokenEditDialog.java @@ -0,0 +1,105 @@ +package com.liskovsoft.smartyoutubetv2.common.utils; + +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.Button; +import android.widget.EditText; +import android.widget.TextView; + +import androidx.appcompat.app.AlertDialog; + +import com.liskovsoft.sharedutils.helpers.KeyHelpers; +import com.liskovsoft.sharedutils.helpers.MessageHelpers; +import com.liskovsoft.smartyoutubetv2.common.R; + +public final class VotTokenEditDialog { + public interface OnSave { + void onSave(String token); + } + + private VotTokenEditDialog() { + } + + public static void show(Context context, String currentToken, OnSave onSave) { + show(context, currentToken, null, onSave); + } + + public static void show(Context context, String currentToken, String clipboardPrefill, OnSave onSave) { + AlertDialog.Builder builder = new AlertDialog.Builder(context, R.style.AppDialog); + View contentView = LayoutInflater.from(context).inflate(R.layout.vot_token_edit_dialog, null); + + TextView hint = contentView.findViewById(R.id.vot_token_hint); + EditText editField = contentView.findViewById(R.id.vot_token_value); + Button pasteBtn = contentView.findViewById(R.id.vot_token_paste); + Button clearBtn = contentView.findViewById(R.id.vot_token_clear); + + hint.setText(R.string.vot_token_dialog_hint); + KeyHelpers.fixShowKeyboard(editField); + + String initial = clipboardPrefill != null ? clipboardPrefill : currentToken; + if (initial != null && !initial.isEmpty()) { + editField.setText(initial); + editField.setSelection(initial.length()); + } + + AlertDialog dialog = builder + .setTitle(R.string.vot_token_dialog_title) + .setView(contentView) + .setPositiveButton(android.R.string.ok, null) + .setNegativeButton(android.R.string.cancel, (d, w) -> d.dismiss()) + .create(); + + pasteBtn.setOnClickListener(v -> { + String clip = readClipboard(context); + if (clip == null || clip.isEmpty()) { + MessageHelpers.showMessage(context, R.string.vot_clipboard_empty); + return; + } + editField.setText(clip.trim()); + editField.setSelection(editField.getText().length()); + }); + + clearBtn.setOnClickListener(v -> { + editField.setText(""); + editField.requestFocus(); + }); + + try { + dialog.show(); + } catch (RuntimeException e) { + MessageHelpers.showMessage(context, e.getMessage()); + return; + } + + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(v -> { + String value = editField.getText().toString().trim(); + onSave.onSave(value); + dialog.dismiss(); + }); + } + + public static void pasteFromClipboard(Context context, OnSave onSave) { + String clip = readClipboard(context); + if (clip == null || clip.isEmpty()) { + MessageHelpers.showMessage(context, R.string.vot_clipboard_empty); + return; + } + show(context, "", clip.trim(), onSave); + } + + private static String readClipboard(Context context) { + ClipboardManager clipboard = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); + if (clipboard == null || !clipboard.hasPrimaryClip()) { + return null; + } + ClipData clip = clipboard.getPrimaryClip(); + if (clip == null || clip.getItemCount() == 0) { + return null; + } + CharSequence text = clip.getItemAt(0).getText(); + return text != null ? text.toString() : null; + } +} diff --git a/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/vot/TranslationAudioPlayer.java b/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/vot/TranslationAudioPlayer.java new file mode 100644 index 0000000000..139f90312c --- /dev/null +++ b/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/vot/TranslationAudioPlayer.java @@ -0,0 +1,117 @@ +package com.liskovsoft.smartyoutubetv2.common.vot; + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.Nullable; + +import com.google.android.exoplayer2.ExoPlaybackException; +import com.google.android.exoplayer2.ExoPlayerFactory; +import com.google.android.exoplayer2.PlaybackParameters; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.source.ExtractorMediaSource; +import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; +import com.google.android.exoplayer2.util.Util; +import com.liskovsoft.sharedutils.mylogger.Log; + +public class TranslationAudioPlayer implements Player.EventListener { + private static final String TAG = TranslationAudioPlayer.class.getSimpleName(); + + public interface OnReadyListener { + void onReady(); + } + + private final Context mContext; + private SimpleExoPlayer mPlayer; + @Nullable + private OnReadyListener mOnReadyListener; + private boolean mReadyFired; + + public TranslationAudioPlayer(Context context) { + mContext = context.getApplicationContext(); + } + + public void play(String url, long startPositionMs, float volume, float speed) { + release(); + mReadyFired = false; + String userAgent = Util.getUserAgent(mContext, "SmartTubeVOT"); + DefaultDataSourceFactory dataSourceFactory = new DefaultDataSourceFactory(mContext, userAgent); + ExtractorMediaSource mediaSource = new ExtractorMediaSource.Factory(dataSourceFactory) + .createMediaSource(Uri.parse(url)); + + mPlayer = ExoPlayerFactory.newSimpleInstance(mContext); + mPlayer.addListener(this); + mPlayer.setVolume(volume); + if (speed > 0f && speed != 1f) { + mPlayer.setPlaybackParameters(new PlaybackParameters(speed, 1f)); + } + mPlayer.prepare(mediaSource); + mPlayer.setPlayWhenReady(true); + if (startPositionMs > 0) { + mPlayer.seekTo(startPositionMs); + } + } + + public void setOnReadyListener(@Nullable OnReadyListener listener) { + mOnReadyListener = listener; + } + + public void setPlaybackSpeed(float speed) { + if (mPlayer == null) { + return; + } + float s = speed > 0f ? speed : 1f; + mPlayer.setPlaybackParameters(new PlaybackParameters(s, 1f)); + } + + public boolean isReady() { + return mPlayer != null && mPlayer.getPlaybackState() == Player.STATE_READY; + } + + public void seekTo(long positionMs) { + if (mPlayer != null) { + mPlayer.seekTo(positionMs); + } + } + + public long getPositionMs() { + return mPlayer != null ? mPlayer.getCurrentPosition() : 0; + } + + public void pause() { + if (mPlayer != null) { + mPlayer.setPlayWhenReady(false); + } + } + + public void resume() { + if (mPlayer != null) { + mPlayer.setPlayWhenReady(true); + } + } + + public void release() { + if (mPlayer != null) { + mPlayer.removeListener(this); + mPlayer.release(); + mPlayer = null; + } + mReadyFired = false; + } + + @Override + public void onPlayerStateChanged(boolean playWhenReady, int playbackState) { + if (playbackState == Player.STATE_READY && !mReadyFired) { + mReadyFired = true; + if (mOnReadyListener != null) { + mOnReadyListener.onReady(); + } + } + } + + @Override + public void onPlayerError(ExoPlaybackException error) { + Log.e(TAG, "Translation player error: %s", error.getMessage()); + } +} diff --git a/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/vot/VotAudioTrackHelper.java b/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/vot/VotAudioTrackHelper.java new file mode 100644 index 0000000000..82d11afc6c --- /dev/null +++ b/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/vot/VotAudioTrackHelper.java @@ -0,0 +1,445 @@ +package com.liskovsoft.smartyoutubetv2.common.vot; + +import androidx.annotation.Nullable; + +import com.liskovsoft.smartyoutubetv2.common.exoplayer.selector.FormatItem; + +import java.util.List; +import java.util.Locale; + +/** + * Parses YouTube audio track labels (e.g. {@code en-US (dubbed-auto)}, {@code ru (original)}). + */ +public final class VotAudioTrackHelper { + private VotAudioTrackHelper() { + } + + public static final class TrackInfo { + @Nullable + public final FormatItem format; + @Nullable + public final String rawLabel; + @Nullable + public final String langCode; + @Nullable + public final String acont; + + public TrackInfo(@Nullable FormatItem format, @Nullable String rawLabel, + @Nullable String langCode, @Nullable String acont) { + this.format = format; + this.rawLabel = rawLabel; + this.langCode = langCode; + this.acont = acont; + } + } + + public static TrackInfo from(@Nullable FormatItem format) { + if (format == null) { + return new TrackInfo(null, null, null, null); + } + return parse(format, pickRawLabel(format)); + } + + @Nullable + private static String pickRawLabel(@Nullable FormatItem format) { + if (format == null) { + return null; + } + String lang = format.getLanguage(); + if (lang != null && !lang.isEmpty()) { + return lang; + } + CharSequence title = format.getTitle(); + if (title == null) { + return null; + } + String t = title.toString().toLowerCase(Locale.US); + if (t.contains("english") || t.contains("original") || t.contains("dubbed") + || t.matches(".*\\ben[- ]?(us|gb)?\\b.*")) { + return title.toString(); + } + return null; + } + + private static TrackInfo parse(@Nullable FormatItem format, @Nullable String raw) { + if (raw == null || raw.isEmpty()) { + return new TrackInfo(format, raw, null, null); + } + String acont = null; + String langPart = raw; + int open = raw.indexOf('('); + if (open > 0 && raw.endsWith(")")) { + langPart = raw.substring(0, open).trim(); + acont = raw.substring(open + 1, raw.length() - 1).trim().toLowerCase(Locale.US); + } + String langCode = extractLangCode(langPart); + return new TrackInfo(format, raw, langCode, acont); + } + + public static TrackInfo resolveCurrent(@Nullable FormatItem current, + @Nullable List allFormats) { + TrackInfo info = from(current); + if (info.langCode != null || info.acont != null) { + return info; + } + TrackInfo fromList = resolveFromList(allFormats); + return fromList != null ? fromList : info; + } + + @Nullable + public static TrackInfo resolveFromList(@Nullable List formats) { + if (formats == null || formats.isEmpty()) { + return null; + } + for (FormatItem format : formats) { + if (format == null) { + continue; + } + if (format.isSelected() || format.isDefault()) { + TrackInfo info = from(format); + if (info.langCode != null || info.acont != null) { + return info; + } + } + } + for (FormatItem format : formats) { + if (format != null) { + TrackInfo info = from(format); + if (info.langCode != null || info.acont != null) { + return info; + } + } + } + return null; + } + + public static boolean isYoutubeAutoDub(@Nullable TrackInfo info) { + return info != null && info.acont != null && info.acont.contains("dubbed-auto"); + } + + public static boolean isDubbed(@Nullable TrackInfo info) { + return info != null && info.acont != null + && (info.acont.contains("dubbed") || info.acont.contains("dubbed-auto")); + } + + public static boolean isOriginalTrack(@Nullable TrackInfo info) { + if (info == null) { + return false; + } + if (info.acont == null) { + return !isDubbed(info); + } + return "original".equals(info.acont) || "descriptive".equals(info.acont); + } + + public static boolean isRussianLang(@Nullable String langCode) { + return langCode != null && langCode.startsWith("ru"); + } + + public static boolean isEnglishLang(@Nullable String langCode) { + return langCode != null && langCode.startsWith("en"); + } + + /** Russian source audio that does not need Yandex (original ru track). */ + public static boolean isRussianOriginal(@Nullable TrackInfo info) { + if (info == null || !isRussianLang(info.langCode)) { + return false; + } + if (isYoutubeAutoDub(info)) { + return false; + } + return isOriginalTrack(info) || !isDubbed(info); + } + + /** Content already in Russian via YouTube auto-dub. */ + public static boolean isYoutubeRussianAutoDub(@Nullable TrackInfo info) { + return info != null && isRussianLang(info.langCode) && isYoutubeAutoDub(info); + } + + public static boolean shouldStartYandex(@Nullable TrackInfo info) { + if (info == null) { + return false; + } + if (isRussianOriginal(info)) { + return false; + } + if (isYoutubeRussianAutoDub(info)) { + return false; + } + return info.langCode != null && !isRussianLang(info.langCode); + } + + public static boolean hasNonRussianOriginalCandidate(@Nullable List formats) { + if (formats == null) { + return false; + } + for (FormatItem format : formats) { + TrackInfo info = from(format); + if (info.langCode != null && !isRussianLang(info.langCode) && isOriginalTrack(info)) { + return true; + } + if (info.langCode != null && !isRussianLang(info.langCode) && !isDubbed(info)) { + return true; + } + // Legacy stream: bare "en" without xtags / (original) — still foreign source audio. + if (isEnglishLang(info.langCode) && !hasRussianOriginalInList(formats) + && !hasYoutubeRussianAutoDubInList(formats)) { + return true; + } + } + return false; + } + + public static boolean hasYoutubeRussianAutoDubInList(@Nullable List formats) { + if (formats == null) { + return false; + } + for (FormatItem format : formats) { + if (isYoutubeRussianAutoDub(from(format))) { + return true; + } + } + return false; + } + + /** + * YouTube served a single-language English stream (no xtags, no ru dub branch). + * Typical yt-dlp label: {@code [en]} only — SmartTube Exo often has empty {@code Format.language}. + */ + public static boolean isLegacyEnglishStream(@Nullable TrackInfo current, + @Nullable List formats) { + if (formats == null || formats.isEmpty()) { + return false; + } + if (hasRussianOriginalInList(formats) || hasYoutubeRussianAutoDubInList(formats)) { + return false; + } + if (current != null && isEnglishLang(current.langCode) && !isYoutubeRussianAutoDub(current)) { + return true; + } + return hasEnglishLangInList(formats); + } + + /** + * Last resort when Exo still exposes only bitrate rows without {@code Format.language}. + */ + public static boolean isMetadataPoorNonRussianAudio(@Nullable List formats) { + return isMetadataPoorNonRussianAudioInternal(formats); + } + + private static boolean hasEnglishLangInList(@Nullable List formats) { + if (formats == null) { + return false; + } + for (FormatItem format : formats) { + if (isEnglishLang(from(format).langCode)) { + return true; + } + } + return false; + } + + /** Bitrate variants only — no lang in Exo yet, and no Russian markers in labels. */ + private static boolean isMetadataPoorNonRussianAudioInternal(@Nullable List formats) { + if (formats == null || formats.isEmpty()) { + return false; + } + boolean sawAudio = false; + boolean sawLang = false; + for (FormatItem format : formats) { + if (format == null || format.getType() != FormatItem.TYPE_AUDIO) { + continue; + } + sawAudio = true; + TrackInfo info = from(format); + if (info.langCode != null) { + sawLang = true; + if (isRussianLang(info.langCode)) { + return false; + } + } + if (info.rawLabel != null) { + String raw = info.rawLabel.toLowerCase(Locale.US); + if (raw.contains("russian") || raw.contains(" ru ") || raw.startsWith("ru ") + || raw.contains("(ru") || raw.contains("ru (")) { + return false; + } + } + } + return sawAudio && !sawLang; + } + + /** + * Mirrors manual button policy: start Yandex unless clearly Russian original content. + */ + public static boolean shouldAutoStartLikeManual(@Nullable TrackInfo current, + @Nullable List formats) { + if (isLikelyRussianContent(current, formats)) { + return false; + } + if (isRussianOriginal(current)) { + return false; + } + if (isYoutubeRussianAutoDub(current)) { + return hasNonRussianOriginalCandidate(formats); + } + if (shouldStartYandex(current)) { + return true; + } + if (hasNonRussianOriginalCandidate(formats)) { + return true; + } + if (current != null && current.langCode != null && !isRussianLang(current.langCode)) { + return true; + } + if (isLegacyEnglishStream(current, formats)) { + return true; + } + return false; + } + + /** After metadata retries: same gate as the manual translate button. */ + public static boolean canStartLikeManualButton(@Nullable TrackInfo current) { + return !isRussianOriginal(current); + } + + public static boolean hasRussianOriginalInList(@Nullable List formats) { + if (formats == null) { + return false; + } + for (FormatItem format : formats) { + if (isRussianOriginal(from(format))) { + return true; + } + } + return false; + } + + /** True when auto-translate should not run (Russian source content). */ + public static boolean isLikelyRussianContent(@Nullable TrackInfo current, + @Nullable List formats) { + if (isRussianOriginal(current)) { + return true; + } + if (formats == null || formats.isEmpty()) { + return false; + } + if (hasRussianOriginalInList(formats) && !hasNonRussianOriginalCandidate(formats)) { + return true; + } + return onlyRussianOriginalTracks(formats); + } + + private static boolean onlyRussianOriginalTracks(@Nullable List formats) { + if (formats == null || formats.isEmpty()) { + return false; + } + boolean sawRussianOriginal = false; + boolean sawNonRussian = false; + for (FormatItem format : formats) { + TrackInfo info = from(format); + if (isRussianOriginal(info)) { + sawRussianOriginal = true; + } + if (info.langCode != null && !isRussianLang(info.langCode)) { + sawNonRussian = true; + } + } + return sawRussianOriginal && !sawNonRussian; + } + + @Nullable + public static String formatTracksForLog(@Nullable List formats) { + if (formats == null || formats.isEmpty()) { + return "[]"; + } + StringBuilder sb = new StringBuilder("["); + for (int i = 0; i < formats.size(); i++) { + FormatItem f = formats.get(i); + if (i > 0) { + sb.append(", "); + } + TrackInfo info = from(f); + sb.append(info.rawLabel != null ? info.rawLabel : "?"); + if (f != null && f.isSelected()) { + sb.append("*"); + } + } + sb.append("]"); + return sb.toString(); + } + + @Nullable + public static FormatItem findYoutubeRussianAutoDub(@Nullable List formats) { + if (formats == null) { + return null; + } + FormatItem fallbackRuDub = null; + for (FormatItem format : formats) { + TrackInfo info = from(format); + if (isYoutubeRussianAutoDub(info)) { + return format; + } + if (isRussianLang(info.langCode) && isDubbed(info)) { + fallbackRuDub = format; + } + } + return fallbackRuDub; + } + + @Nullable + public static FormatItem findBestOriginalForYandex(@Nullable List formats) { + if (formats == null) { + return null; + } + FormatItem originalNonRu = null; + FormatItem anyNonRu = null; + FormatItem anyOriginal = null; + for (FormatItem format : formats) { + if (format == null) { + continue; + } + TrackInfo info = from(format); + if (isYoutubeAutoDub(info) || isYoutubeRussianAutoDub(info)) { + continue; + } + if (isOriginalTrack(info) && info.langCode != null && !isRussianLang(info.langCode)) { + originalNonRu = format; + } + if (info.langCode != null && !isRussianLang(info.langCode) && !isDubbed(info)) { + anyNonRu = format; + } + if (isOriginalTrack(info) && anyOriginal == null) { + anyOriginal = format; + } + } + if (originalNonRu != null) { + return originalNonRu; + } + if (anyNonRu != null) { + return anyNonRu; + } + return anyOriginal; + } + + public static boolean isSameFormat(@Nullable FormatItem a, @Nullable FormatItem b) { + if (a == null || b == null) { + return false; + } + return a.getId() == b.getId(); + } + + @Nullable + private static String extractLangCode(@Nullable String langPart) { + if (langPart == null || langPart.isEmpty()) { + return null; + } + String trimmed = langPart.trim(); + int dash = trimmed.indexOf('-'); + String code = dash > 0 ? trimmed.substring(0, dash) : trimmed; + if (code.length() >= 2) { + return code.toLowerCase(Locale.US); + } + return null; + } +} diff --git a/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/vot/VotClient.java b/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/vot/VotClient.java new file mode 100644 index 0000000000..28641cbfe7 --- /dev/null +++ b/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/vot/VotClient.java @@ -0,0 +1,233 @@ +package com.liskovsoft.smartyoutubetv2.common.vot; + +import android.content.Context; + +import androidx.annotation.Nullable; + +import com.liskovsoft.sharedutils.mylogger.Log; +import com.liskovsoft.smartyoutubetv2.common.prefs.VotData; + +import org.json.JSONObject; + +import io.reactivex.Observable; +import io.reactivex.ObservableEmitter; +import io.reactivex.schedulers.Schedulers; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +public class VotClient { + private static final String TAG = VotClient.class.getSimpleName(); + private static final int MAX_POLL_ATTEMPTS = 60; + + private final VotHttp mHttp = new VotHttp(); + private final VotData mVotData; + @Nullable + private VotSession mSession; + + public VotClient(Context context) { + mVotData = VotData.instance(context); + } + + public String translateToRussian(String youtubeUrl, long durationSec) throws IOException, VotException { + VotProgress last = observeTranslation(youtubeUrl, durationSec) + .blockingLast(); + if (last == null) { + throw new VotException("Translation cancelled"); + } + if (last.type == VotProgress.TYPE_READY && last.audioUrl != null) { + return last.audioUrl; + } + if (last.type == VotProgress.TYPE_FAILED) { + throw new VotException(last.message != null ? last.message : "Translation failed"); + } + throw new VotException("Translation timeout"); + } + + public Observable observeTranslation(String youtubeUrl, long durationSec) { + return Observable.create(emitter -> pollTranslation(emitter, youtubeUrl, durationSec, true)) + .subscribeOn(Schedulers.io()); + } + + private void pollTranslation(ObservableEmitter emitter, String youtubeUrl, long durationSec, + boolean allowAudioFallback) { + try { + VotTranslationResponse response = requestTranslation(youtubeUrl, durationSec, false); + if (!processResponse(emitter, youtubeUrl, durationSec, allowAudioFallback, response)) { + return; + } + + int waitSec = Math.max(3, response.remainingTimeSec > 0 ? response.remainingTimeSec : 5); + for (int i = 0; i < MAX_POLL_ATTEMPTS && !emitter.isDisposed(); i++) { + sleep(waitSec); + if (emitter.isDisposed()) { + return; + } + response = requestTranslation(youtubeUrl, durationSec, true); + if (!processResponse(emitter, youtubeUrl, durationSec, allowAudioFallback, response)) { + return; + } + waitSec = Math.max(3, response.remainingTimeSec > 0 ? response.remainingTimeSec : waitSec); + } + if (!emitter.isDisposed()) { + emitter.onNext(VotProgress.failed("Translation timeout")); + emitter.onComplete(); + } + } catch (IOException e) { + if (!emitter.isDisposed()) { + emitter.onError(e); + } + } catch (VotException e) { + if (!emitter.isDisposed()) { + emitter.onNext(VotProgress.failed(e.getMessage())); + emitter.onComplete(); + } + } + } + + /** @return false if polling should stop (ready, failed, or disposed) */ + private boolean processResponse(ObservableEmitter emitter, String youtubeUrl, long durationSec, + boolean allowAudioFallback, VotTranslationResponse response) + throws IOException, VotException { + if (emitter.isDisposed()) { + return false; + } + + if (response.status == VotTranslationResponse.STATUS_SESSION_REQUIRED) { + emitter.onNext(VotProgress.failed("auth required")); + emitter.onComplete(); + return false; + } + + if (response.status == VotTranslationResponse.STATUS_AUDIO_REQUESTED && allowAudioFallback) { + handleAudioRequested(youtubeUrl, durationSec, response.translationId); + pollTranslation(emitter, youtubeUrl, durationSec, false); + return false; + } + + if (response.isReady() && response.url != null && !response.url.isEmpty()) { + emitter.onNext(VotProgress.ready(response.url)); + emitter.onComplete(); + return false; + } + + if (response.status == VotTranslationResponse.STATUS_FAILED) { + String msg = response.message != null ? response.message : "Translation failed"; + emitter.onNext(VotProgress.failed(msg)); + emitter.onComplete(); + return false; + } + + if (response.isWaiting() || response.status == VotTranslationResponse.STATUS_AUDIO_REQUESTED) { + emitter.onNext(VotProgress.waiting(response.remainingTimeSec, response.status)); + return true; + } + + emitter.onNext(VotProgress.failed("Unexpected translation status: " + response.status)); + emitter.onComplete(); + return false; + } + + private VotTranslationResponse requestTranslation(String youtubeUrl, double durationSec, boolean subsequent) + throws IOException { + boolean useLively = mVotData.isLivelyVoiceEnabled(); + byte[] body = VotProtobuf.encodeTranslationRequest( + youtubeUrl, + durationSec, + VotConfig.REQUEST_LANG, + VotConfig.RESPONSE_LANG, + !subsequent, + useLively + ); + + Map headers = buildTranslateHeaders(body); + byte[] raw = mHttp.postProtobuf("/video-translation/translate", body, headers); + + if (raw == null || raw.length == 0) { + throw new IOException("Empty translation response"); + } + return VotProtobuf.decodeTranslationResponse(raw); + } + + private Map buildTranslateHeaders(byte[] body) { + Map headers; + if (mSession != null) { + headers = VotHeaders.sessionTranslate(mSession, body, "/video-translation/translate"); + } else { + headers = VotHeaders.simpleTranslate(body); + } + if (mVotData.isLivelyVoiceEnabled()) { + headers = VotHeaders.merge(headers, VotHeaders.oauthHeader(mVotData.getOAuthToken())); + } + return headers; + } + + private void handleAudioRequested(String youtubeUrl, long durationSec, @Nullable String translationId) + throws IOException, VotException { + if (translationId == null || translationId.isEmpty()) { + VotTranslationResponse r = requestTranslation(youtubeUrl, durationSec, false); + translationId = r.translationId; + } + if (translationId == null || translationId.isEmpty()) { + throw new VotException("Missing translationId for audio upload"); + } + + ensureSession(); + requestFailAudio(youtubeUrl); + uploadEmptyAudio(youtubeUrl, translationId); + } + + private void requestFailAudio(String youtubeUrl) throws IOException, VotException { + String json = "{\"video_url\":\"" + youtubeUrl.replace("\"", "\\\"") + "\"}"; + byte[] raw = mHttp.putJson("/video-translation/fail-audio-js", json, + VotHeaders.simpleTranslate(json.getBytes(StandardCharsets.UTF_8))); + if (raw == null) { + throw new VotException("fail-audio-js: empty response"); + } + try { + String text = new String(raw, StandardCharsets.UTF_8); + JSONObject obj = new JSONObject(text); + if (obj.optInt("status", 0) != 1) { + throw new VotException("fail-audio-js failed"); + } + } catch (VotException e) { + throw e; + } catch (Exception e) { + Log.e(TAG, "fail-audio-js parse error: %s", e.getMessage()); + } + } + + private void uploadEmptyAudio(String youtubeUrl, String translationId) throws IOException { + byte[] body = VotProtobuf.encodeTranslationAudioRequest( + youtubeUrl, translationId, VotConfig.FAKE_AUDIO_FILE_ID); + mHttp.putProtobuf("/video-translation/audio", body, + VotHeaders.sessionTranslate(mSession, body, "/video-translation/audio")); + } + + private void ensureSession() throws IOException { + if (mSession != null && System.currentTimeMillis() - mSession.createdAtMs < mSession.expiresSec * 1000L) { + return; + } + String uuid = VotSignature.randomToken(); + byte[] body = VotProtobuf.encodeSessionRequest(uuid, "video-translation"); + byte[] raw = mHttp.postProtobuf("/session/create", body, VotHeaders.simpleTranslate(body)); + if (raw == null) { + throw new IOException("Empty session response"); + } + VotSession decoded = VotProtobuf.decodeSessionResponse(raw); + decoded.uuid = uuid; + decoded.createdAtMs = System.currentTimeMillis(); + mSession = decoded; + } + + private void sleep(int sec) throws VotException { + try { + TimeUnit.SECONDS.sleep(sec); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new VotException("Interrupted"); + } + } +} diff --git a/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/vot/VotConfig.java b/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/vot/VotConfig.java new file mode 100644 index 0000000000..ed16918421 --- /dev/null +++ b/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/vot/VotConfig.java @@ -0,0 +1,16 @@ +package com.liskovsoft.smartyoutubetv2.common.vot; + +public final class VotConfig { + public static final String HOST = "api.browser.yandex.ru"; + public static final String HMAC_KEY = "bt8xH3VOlb4mqf0nqAibnDOoiPlXsisf"; + public static final String USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) " + + "Chrome/122.0.0.0 YaBrowser/24.4.0.0 Safari/537.36"; + public static final String COMPONENT_VERSION = "24.4.0.0"; + public static final String REQUEST_LANG = "en"; + public static final String RESPONSE_LANG = "ru"; + public static final String FAKE_AUDIO_FILE_ID = "web_api_get_all_generating_urls_data_from_iframe"; + + private VotConfig() { + } +} diff --git a/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/vot/VotException.java b/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/vot/VotException.java new file mode 100644 index 0000000000..82dd9c0f59 --- /dev/null +++ b/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/vot/VotException.java @@ -0,0 +1,7 @@ +package com.liskovsoft.smartyoutubetv2.common.vot; + +public class VotException extends Exception { + public VotException(String message) { + super(message); + } +} diff --git a/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/vot/VotHeaders.java b/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/vot/VotHeaders.java new file mode 100644 index 0000000000..52bf2dbf02 --- /dev/null +++ b/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/vot/VotHeaders.java @@ -0,0 +1,54 @@ +package com.liskovsoft.smartyoutubetv2.common.vot; + +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +public final class VotHeaders { + private VotHeaders() { + } + + public static Map simpleTranslate(byte[] body) { + Map headers = new HashMap<>(); + headers.put("Vtrans-Signature", VotSignature.sign(body)); + headers.put("Sec-Vtrans-Token", VotSignature.randomToken()); + return headers; + } + + public static Map sessionTranslate(VotSession session, byte[] body, String path) { + return buildSecHeaders("Vtrans", session, body, path); + } + + public static Map merge(Map base, Map extra) { + if (extra == null || extra.isEmpty()) { + return base; + } + if (base == null || base.isEmpty()) { + return extra; + } + Map merged = new HashMap<>(base); + merged.putAll(extra); + return merged; + } + + public static Map oauthHeader(String token) { + Map headers = new HashMap<>(); + if (token != null && !token.isEmpty()) { + headers.put("Authorization", "OAuth " + token); + } + return headers; + } + + private static Map buildSecHeaders(String secType, VotSession session, byte[] body, String path) { + String token = session.uuid + ":" + path + ":" + VotConfig.COMPONENT_VERSION; + byte[] tokenBody = token.getBytes(StandardCharsets.UTF_8); + String tokenSign = VotSignature.sign(tokenBody); + String fullToken = tokenSign + ":" + token; + + Map headers = new HashMap<>(); + headers.put(secType + "-Signature", VotSignature.sign(body)); + headers.put("Sec-" + secType + "-Sk", session.secretKey); + headers.put("Sec-" + secType + "-Token", fullToken); + return headers; + } +} diff --git a/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/vot/VotHttp.java b/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/vot/VotHttp.java new file mode 100644 index 0000000000..6da5a72a9b --- /dev/null +++ b/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/vot/VotHttp.java @@ -0,0 +1,64 @@ +package com.liskovsoft.smartyoutubetv2.common.vot; + +import androidx.annotation.Nullable; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; + +public class VotHttp { + private static final MediaType PROTOBUF = MediaType.parse("application/x-protobuf"); + private static final MediaType JSON = MediaType.parse("application/json"); + + private final OkHttpClient mClient = new OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(120, TimeUnit.SECONDS) + .writeTimeout(120, TimeUnit.SECONDS) + .build(); + + public byte[] postProtobuf(String path, byte[] body, Map headers) throws IOException { + return execute(path, "POST", RequestBody.create(PROTOBUF, body), headers); + } + + public byte[] putProtobuf(String path, byte[] body, Map headers) throws IOException { + return execute(path, "PUT", RequestBody.create(PROTOBUF, body), headers); + } + + public byte[] putJson(String path, String json, Map headers) throws IOException { + return execute(path, "PUT", RequestBody.create(JSON, json.getBytes(StandardCharsets.UTF_8)), headers); + } + + @Nullable + private byte[] execute(String path, String method, RequestBody requestBody, Map headers) throws IOException { + Request.Builder builder = new Request.Builder() + .url("https://" + VotConfig.HOST + path) + .method(method, requestBody) + .header("Accept", "application/x-protobuf") + .header("Accept-Language", "en") + .header("Content-Type", method.equals("PUT") && requestBody.contentType() == JSON + ? "application/json" : "application/x-protobuf") + .header("User-Agent", VotConfig.USER_AGENT) + .header("Pragma", "no-cache") + .header("Cache-Control", "no-cache"); + + if (headers != null) { + for (Map.Entry e : headers.entrySet()) { + builder.header(e.getKey(), e.getValue()); + } + } + + try (Response response = mClient.newCall(builder.build()).execute()) { + if (response.body() == null) { + return null; + } + return response.body().bytes(); + } + } +} diff --git a/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/vot/VotProgress.java b/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/vot/VotProgress.java new file mode 100644 index 0000000000..71b6b6d75f --- /dev/null +++ b/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/vot/VotProgress.java @@ -0,0 +1,33 @@ +package com.liskovsoft.smartyoutubetv2.common.vot; + +public final class VotProgress { + public static final int TYPE_WAITING = 0; + public static final int TYPE_READY = 1; + public static final int TYPE_FAILED = 2; + + public final int type; + public final String audioUrl; + public final int remainingTimeSec; + public final int status; + public final String message; + + private VotProgress(int type, String audioUrl, int remainingTimeSec, int status, String message) { + this.type = type; + this.audioUrl = audioUrl; + this.remainingTimeSec = remainingTimeSec; + this.status = status; + this.message = message; + } + + public static VotProgress waiting(int remainingTimeSec, int status) { + return new VotProgress(TYPE_WAITING, null, remainingTimeSec, status, null); + } + + public static VotProgress ready(String audioUrl) { + return new VotProgress(TYPE_READY, audioUrl, 0, VotTranslationResponse.STATUS_FINISHED, null); + } + + public static VotProgress failed(String message) { + return new VotProgress(TYPE_FAILED, null, 0, VotTranslationResponse.STATUS_FAILED, message); + } +} diff --git a/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/vot/VotProtobuf.java b/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/vot/VotProtobuf.java new file mode 100644 index 0000000000..b97dc2add6 --- /dev/null +++ b/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/vot/VotProtobuf.java @@ -0,0 +1,147 @@ +package com.liskovsoft.smartyoutubetv2.common.vot; + +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +public final class VotProtobuf { + private VotProtobuf() { + } + + public static byte[] encodeTranslationRequest( + String url, + double duration, + String requestLang, + String responseLang, + boolean firstRequest, + boolean useLivelyVoice + ) { + VotWireWriter w = new VotWireWriter(); + w.writeString(3, url); + w.writeBool(5, firstRequest); + w.writeDouble(6, duration); + w.writeInt32(7, 1); + w.writeString(8, requestLang); + w.writeBool(9, false); + w.writeInt32(10, 0); + w.writeString(14, responseLang); + w.writeInt32(15, 1); + w.writeInt32(16, 2); + w.writeBool(18, useLivelyVoice); + return w.toByteArray(); + } + + public static byte[] encodeSessionRequest(String uuid, String module) { + VotWireWriter w = new VotWireWriter(); + w.writeString(1, uuid); + w.writeString(2, module); + return w.toByteArray(); + } + + public static byte[] encodeTranslationAudioRequest(String url, String translationId, String fileId) { + VotWireWriter audioInfo = new VotWireWriter(); + audioInfo.writeString(1, fileId); + audioInfo.writeBytes(2, new byte[0]); + + VotWireWriter w = new VotWireWriter(); + w.writeString(1, translationId); + w.writeString(2, url); + w.writeEmbedded(6, audioInfo.toByteArray()); + return w.toByteArray(); + } + + public static VotTranslationResponse decodeTranslationResponse(byte[] data) { + Map fields = parseFields(data); + VotTranslationResponse r = new VotTranslationResponse(); + r.url = (String) fields.get(1); + Object status = fields.get(4); + r.status = status instanceof Integer ? (Integer) status : 0; + Object remaining = fields.get(5); + r.remainingTimeSec = remaining instanceof Integer ? (Integer) remaining : 0; + r.translationId = (String) fields.get(7); + r.message = (String) fields.get(9); + return r; + } + + public static VotSession decodeSessionResponse(byte[] data) { + Map fields = parseFields(data); + VotSession s = new VotSession(); + s.secretKey = (String) fields.get(1); + Object expires = fields.get(2); + s.expiresSec = expires instanceof Integer ? (Integer) expires : 3600; + return s; + } + + private static Map parseFields(byte[] data) { + Map result = new HashMap<>(); + int pos = 0; + while (pos < data.length) { + int[] tag = readVarint(data, pos); + if (tag[0] < 0) { + break; + } + pos = tag[1]; + int fieldNumber = tag[0] >>> 3; + int wireType = tag[0] & 0x7; + + switch (wireType) { + case 0: { + int[] val = readVarint(data, pos); + pos = val[1]; + result.put(fieldNumber, val[0]); + break; + } + case 1: { + pos += 8; + break; + } + case 2: { + int[] len = readVarint(data, pos); + pos = len[1]; + int length = len[0]; + byte[] chunk = new byte[length]; + System.arraycopy(data, pos, chunk, 0, length); + pos += length; + if (isUtf8String(chunk)) { + result.put(fieldNumber, new String(chunk, StandardCharsets.UTF_8)); + } + break; + } + case 5: { + pos += 4; + break; + } + default: + pos = data.length; + break; + } + } + return result; + } + + private static boolean isUtf8String(byte[] chunk) { + for (byte b : chunk) { + if (b < 0x09) { + return false; + } + } + return true; + } + + private static int[] readVarint(byte[] data, int pos) { + int result = 0; + int shift = 0; + while (pos < data.length) { + int b = data[pos++] & 0xFF; + result |= (b & 0x7F) << shift; + if ((b & 0x80) == 0) { + return new int[]{result, pos}; + } + shift += 7; + if (shift > 35) { + return new int[]{-1, pos}; + } + } + return new int[]{-1, pos}; + } +} diff --git a/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/vot/VotSession.java b/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/vot/VotSession.java new file mode 100644 index 0000000000..6513d634fc --- /dev/null +++ b/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/vot/VotSession.java @@ -0,0 +1,8 @@ +package com.liskovsoft.smartyoutubetv2.common.vot; + +public class VotSession { + public String uuid; + public String secretKey; + public int expiresSec; + public long createdAtMs; +} diff --git a/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/vot/VotSignature.java b/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/vot/VotSignature.java new file mode 100644 index 0000000000..de2c26afd3 --- /dev/null +++ b/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/vot/VotSignature.java @@ -0,0 +1,34 @@ +package com.liskovsoft.smartyoutubetv2.common.vot; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; + +public final class VotSignature { + private VotSignature() { + } + + public static String sign(byte[] body) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(VotConfig.HMAC_KEY.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + byte[] hash = mac.doFinal(body); + StringBuilder sb = new StringBuilder(hash.length * 2); + for (byte b : hash) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } catch (Exception e) { + throw new RuntimeException("VOT HMAC failed", e); + } + } + + public static String randomToken() { + String hex = "0123456789ABCDEF"; + StringBuilder uuid = new StringBuilder(32); + for (int i = 0; i < 32; i++) { + uuid.append(hex.charAt((int) (Math.random() * 16))); + } + return uuid.toString(); + } +} diff --git a/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/vot/VotTranslationResponse.java b/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/vot/VotTranslationResponse.java new file mode 100644 index 0000000000..9d124cb6ea --- /dev/null +++ b/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/vot/VotTranslationResponse.java @@ -0,0 +1,25 @@ +package com.liskovsoft.smartyoutubetv2.common.vot; + +public class VotTranslationResponse { + public static final int STATUS_FAILED = 0; + public static final int STATUS_FINISHED = 1; + public static final int STATUS_WAITING = 2; + public static final int STATUS_LONG_WAITING = 3; + public static final int STATUS_PART_CONTENT = 5; + public static final int STATUS_AUDIO_REQUESTED = 6; + public static final int STATUS_SESSION_REQUIRED = 7; + + public String url; + public int status; + public int remainingTimeSec; + public String translationId; + public String message; + + public boolean isReady() { + return status == STATUS_FINISHED || status == STATUS_PART_CONTENT; + } + + public boolean isWaiting() { + return status == STATUS_WAITING || status == STATUS_LONG_WAITING; + } +} diff --git a/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/vot/VotWireWriter.java b/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/vot/VotWireWriter.java new file mode 100644 index 0000000000..ae15e1c330 --- /dev/null +++ b/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/vot/VotWireWriter.java @@ -0,0 +1,79 @@ +package com.liskovsoft.smartyoutubetv2.common.vot; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; + +final class VotWireWriter { + private final ByteArrayOutputStream mOut = new ByteArrayOutputStream(); + + byte[] toByteArray() { + return mOut.toByteArray(); + } + + void writeString(int fieldNumber, String value) { + if (value == null || value.isEmpty()) { + return; + } + byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + writeTag(fieldNumber, 2); + writeVarint(bytes.length); + mOut.write(bytes, 0, bytes.length); + } + + void writeInt32(int fieldNumber, int value) { + if (value == 0) { + return; + } + writeTag(fieldNumber, 0); + writeVarint(value); + } + + void writeBool(int fieldNumber, boolean value) { + if (!value) { + return; + } + writeTag(fieldNumber, 0); + writeVarint(1); + } + + void writeDouble(int fieldNumber, double value) { + if (value == 0) { + return; + } + writeTag(fieldNumber, 1); + long bits = Double.doubleToLongBits(value); + for (int i = 0; i < 8; i++) { + mOut.write((int) (bits >> (i * 8)) & 0xFF); + } + } + + void writeBytes(int fieldNumber, byte[] value) { + if (value == null || value.length == 0) { + return; + } + writeTag(fieldNumber, 2); + writeVarint(value.length); + mOut.write(value, 0, value.length); + } + + void writeEmbedded(int fieldNumber, byte[] embedded) { + if (embedded == null || embedded.length == 0) { + return; + } + writeTag(fieldNumber, 2); + writeVarint(embedded.length); + mOut.write(embedded, 0, embedded.length); + } + + private void writeTag(int fieldNumber, int wireType) { + writeVarint((fieldNumber << 3) | wireType); + } + + private void writeVarint(int value) { + while ((value & ~0x7F) != 0) { + mOut.write((value & 0x7F) | 0x80); + value >>>= 7; + } + mOut.write(value); + } +} diff --git a/common/src/main/res/layout/vot_token_edit_dialog.xml b/common/src/main/res/layout/vot_token_edit_dialog.xml new file mode 100644 index 0000000000..ed51544fec --- /dev/null +++ b/common/src/main/res/layout/vot_token_edit_dialog.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + +