diff --git a/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/app/presenters/dialogs/AppUpdatePresenter.java b/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/app/presenters/dialogs/AppUpdatePresenter.java index 7cf23d1..618ced1 100644 --- a/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/app/presenters/dialogs/AppUpdatePresenter.java +++ b/common/src/main/java/com/liskovsoft/smartyoutubetv2/common/app/presenters/dialogs/AppUpdatePresenter.java @@ -49,6 +49,11 @@ public class AppUpdatePresenter extends BasePresenter implements AppUpdate } public void start(boolean forceCheck) { + // This independently signed fork is updated with the karaoke app, never by upstream APKs. + if ("com.macodo.ktvplayer".equals(getContext().getPackageName())) { + if (forceCheck) MessageHelpers.showMessage(getContext(), "請從家庭點唱機更新 KTV 播放輔助版"); + return; + } mIsForceCheck = forceCheck; if (forceCheck) { diff --git a/exoplayer-amzn-2.10.6/library/core/src/main/java/com/google/android/exoplayer2/DefaultRenderersFactory.java b/exoplayer-amzn-2.10.6/library/core/src/main/java/com/google/android/exoplayer2/DefaultRenderersFactory.java index 6c42b56..9617735 100644 --- a/exoplayer-amzn-2.10.6/library/core/src/main/java/com/google/android/exoplayer2/DefaultRenderersFactory.java +++ b/exoplayer-amzn-2.10.6/library/core/src/main/java/com/google/android/exoplayer2/DefaultRenderersFactory.java @@ -548,7 +548,7 @@ public class DefaultRenderersFactory implements RenderersFactory { * Builds an array of {@link AudioProcessor}s that will process PCM audio before output. */ protected AudioProcessor[] buildAudioProcessors() { - return new AudioProcessor[0]; + return new AudioProcessor[] {new com.google.android.exoplayer2.audio.KaraokeChannelAudioProcessor()}; } } diff --git a/smarttubetv/build.gradle b/smarttubetv/build.gradle index 3664bfd..00e626e 100644 --- a/smarttubetv/build.gradle +++ b/smarttubetv/build.gradle @@ -31,8 +31,9 @@ if (keystorePropertiesFile.exists()) { } // The first letter of the flavor name must be in uppercase -if (new File("${projectDir}/google-services.json").exists() && (getGradle().getStartParameter().getTaskRequests().toString().contains("Stbeta") || - getGradle().getStartParameter().getTaskRequests().toString().contains("Ststable"))) { +// The independently packaged KTV flavor does not use upstream Firebase configuration. +if (new File("${projectDir}/google-services.json").exists() && + getGradle().getStartParameter().getTaskRequests().toString().contains("Ststable")) { // Google Services Gradle plugin (transforms google-services.json file) apply plugin: 'com.google.gms.google-services' @@ -57,8 +58,8 @@ android { applicationId "app.smarttube" minSdkVersion project.properties.minSdkVersion targetSdkVersion project.properties.targetSdkVersion - versionCode 2458 - versionName "32.68" + versionCode 1 + versionName "32.68-ktv.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" buildConfigField "long", "TIMESTAMP", System.currentTimeMillis() + "L" @@ -160,7 +161,7 @@ android { // NOTE: The main goal of prefixing a flavor is to group flavors' folders together productFlavors { stbeta { - applicationId "org.smarttube.beta" + applicationId "com.macodo.ktvplayer" targetSdkVersion project.properties.compileSdkVersion } ststable { diff --git a/smarttubetv/src/main/AndroidManifest.xml b/smarttubetv/src/main/AndroidManifest.xml index 2923eba..817f969 100644 --- a/smarttubetv/src/main/AndroidManifest.xml +++ b/smarttubetv/src/main/AndroidManifest.xml @@ -54,10 +54,10 @@ android:banner="@mipmap/app_banner" android:logo="@mipmap/app_logo" android:icon="@mipmap/app_icon" - android:label="@string/app_name" + android:label="家庭 KTV 播放輔助" android:theme="@style/App.Theme.Leanback.Browse" tools:node="merge" - tools:replace="android:theme,android:allowBackup" + tools:replace="android:theme,android:allowBackup,android:label" tools:ignore="GoogleAppIndexingWarning,LockedOrientationActivity,UnusedAttribute,DiscouragedApi"> @@ -311,4 +311,4 @@ - \ No newline at end of file + diff --git a/smarttubetv/src/main/java/com/liskovsoft/smartyoutubetv2/tv/ui/playback/PlaybackFragment.java b/smarttubetv/src/main/java/com/liskovsoft/smartyoutubetv2/tv/ui/playback/PlaybackFragment.java index 58d1049..587f466 100644 --- a/smarttubetv/src/main/java/com/liskovsoft/smartyoutubetv2/tv/ui/playback/PlaybackFragment.java +++ b/smarttubetv/src/main/java/com/liskovsoft/smartyoutubetv2/tv/ui/playback/PlaybackFragment.java @@ -3,6 +3,9 @@ package com.liskovsoft.smartyoutubetv2.tv.ui.playback; import android.media.session.PlaybackState; import android.os.Build.VERSION; import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.os.SystemClock; import android.support.v4.media.MediaMetadataCompat; import android.support.v4.media.session.MediaSessionCompat; import android.support.v4.media.session.PlaybackStateCompat; @@ -40,6 +43,9 @@ import com.google.android.exoplayer2.DefaultControlDispatcher; import com.google.android.exoplayer2.DefaultRenderersFactory; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.audio.KaraokePcmMixer; +import com.google.android.exoplayer2.audio.KaraokeChannelAudioProcessor; +import com.google.android.exoplayer2.decoder.DecoderCounters; import com.google.android.exoplayer2.ext.leanback.LeanbackPlayerAdapter; import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector; import com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection; @@ -120,6 +126,34 @@ public class PlaybackFragment extends SeekModePlaybackFragment implements Playba private boolean mIsEngineBlocked; private MediaSessionCompat mMediaSession; private MediaSessionConnector mMediaSessionConnector; + private final Handler mKaraokeHandler = new Handler(Looper.getMainLooper()); + private final Runnable mKaraokeTick = new Runnable() { + @Override public void run() { + if (mMediaSession == null || mPlayer == null) return; + publishKaraokeState(); + mKaraokeHandler.postDelayed(this, 1000); + } + }; + + private void publishKaraokeState() { + if (mMediaSession == null || mPlayer == null) return; + Bundle extras = new Bundle(); + extras.putInt("ktvProtocol", 1); + extras.putInt("channelMode", KaraokeChannelAudioProcessor.getAppliedMode()); + extras.putString("videoId", getVideo() == null ? "" : getVideo().videoId); + extras.putLong("positionMs", mPlayer.getCurrentPosition()); + extras.putLong("durationMs", mPlayer.getDuration()); + extras.putLong("sampleTimeMs", SystemClock.elapsedRealtime()); + extras.putInt("playerState", mPlayer.getPlaybackState()); + extras.putBoolean("playWhenReady", mPlayer.getPlayWhenReady()); + DecoderCounters video = mPlayer.getVideoDecoderCounters(); + DecoderCounters audio = mPlayer.getAudioDecoderCounters(); + if (video != null) video.ensureUpdated(); + if (audio != null) audio.ensureUpdated(); + extras.putInt("videoFrames", video == null ? -1 : video.renderedOutputBufferCount); + extras.putInt("audioFrames", audio == null ? -1 : audio.renderedOutputBufferCount); + mMediaSession.setExtras(extras); + } private DoubleTapPlayerAdapter mDoubleTapPlayerAdapter; private YouTubeOverlay mYouTubeOverlay; private Boolean mIsControlsShownPreviously; @@ -417,6 +451,7 @@ public class PlaybackFragment extends SeekModePlaybackFragment implements Playba mRowsSupportFragment = null; } if (mMediaSessionConnector != null) { + mKaraokeHandler.removeCallbacks(mKaraokeTick); mMediaSessionConnector.setPlayer(null); mMediaSessionConnector.setControlDispatcher(null); mMediaSessionConnector.setMediaMetadataProvider(null); @@ -616,6 +651,22 @@ public class PlaybackFragment extends SeekModePlaybackFragment implements Playba mMediaSession = new MediaSessionCompat(getContext().getApplicationContext(), getContext().getPackageName()); // NOTE: mem leak fix (SegmentTimelineElement) mMediaSession.setActive(!disableNotifications); mMediaSessionConnector = new MediaSessionConnector(mMediaSession); + KaraokePcmMixer.setMode(getContext().getSharedPreferences("karaoke", 0).getInt("channelMode", 0)); + mMediaSessionConnector.setCustomActionProviders(new MediaSessionConnector.CustomActionProvider() { + @Override public void onCustomAction(Player player, ControlDispatcher dispatcher, String action, Bundle extras) { + if (extras == null) return; + int mode = extras.getInt("mode", -1); + if (mode < 0 || mode > 2) return; + KaraokePcmMixer.setMode(mode); + getContext().getSharedPreferences("karaoke", 0).edit().putInt("channelMode", mode).apply(); + publishKaraokeState(); + } + @Override public PlaybackStateCompat.CustomAction getCustomAction(Player player) { + return new PlaybackStateCompat.CustomAction.Builder("com.macodo.ktvplayer.SET_CHANNEL", "聲道", android.R.drawable.ic_media_play).build(); + } + }); + mKaraokeHandler.removeCallbacks(mKaraokeTick); + mKaraokeHandler.post(mKaraokeTick); try { mMediaSessionConnector.setPlayer(mPlayer); diff --git a/exoplayer-amzn-2.10.6/library/core/src/main/java/com/google/android/exoplayer2/audio/KaraokePcmMixer.java b/exoplayer-amzn-2.10.6/library/core/src/main/java/com/google/android/exoplayer2/audio/KaraokePcmMixer.java new file mode 100644 index 0000000..5c66ec5 --- /dev/null +++ b/exoplayer-amzn-2.10.6/library/core/src/main/java/com/google/android/exoplayer2/audio/KaraokePcmMixer.java @@ -0,0 +1,30 @@ +package com.google.android.exoplayer2.audio; + +import java.nio.ByteBuffer; + +/** KTV-only extension: duplicate the chosen input channel to both speakers. */ +public final class KaraokePcmMixer { + public static final int STEREO = 0, LEFT = 1, RIGHT = 2; + private static volatile int mode = STEREO; + private KaraokePcmMixer() {} + public static int getMode() { return mode; } + public static void setMode(int value) { + if (value < STEREO || value > RIGHT) throw new IllegalArgumentException("Unknown channel mode"); + mode = value; + } + public static void mixStereo(ByteBuffer input, ByteBuffer output, int bytesPerSample, int selectedMode) { + if ((bytesPerSample != 2 && bytesPerSample != 4) || selectedMode < 0 || selectedMode > 2 || + input.remaining() % (bytesPerSample * 2) != 0 || output.remaining() < input.remaining()) { + throw new IllegalArgumentException("Invalid stereo PCM buffer"); + } + if (selectedMode == STEREO) { output.put(input); return; } + int limit = input.limit(); + for (int frame = input.position(); frame < limit; frame += bytesPerSample * 2) { + int sample = frame + (selectedMode == LEFT ? 0 : bytesPerSample); + for (int channel = 0; channel < 2; channel++) { + for (int b = 0; b < bytesPerSample; b++) output.put(input.get(sample + b)); + } + } + input.position(limit); + } +} diff --git a/exoplayer-amzn-2.10.6/library/core/src/main/java/com/google/android/exoplayer2/audio/KaraokeChannelAudioProcessor.java b/exoplayer-amzn-2.10.6/library/core/src/main/java/com/google/android/exoplayer2/audio/KaraokeChannelAudioProcessor.java new file mode 100644 index 0000000..3ab538b --- /dev/null +++ b/exoplayer-amzn-2.10.6/library/core/src/main/java/com/google/android/exoplayer2/audio/KaraokeChannelAudioProcessor.java @@ -0,0 +1,28 @@ +package com.google.android.exoplayer2.audio; + +import com.google.android.exoplayer2.C; +import java.nio.ByteBuffer; + +/** Remains in the stereo PCM pipeline so channel selection can change without rebuffering. */ +public final class KaraokeChannelAudioProcessor extends BaseAudioProcessor { + private boolean active; + private static volatile int appliedMode = -1; + public static int getAppliedMode() { return appliedMode; } + private int bytesPerSample = 2; + @Override public boolean configure(int sampleRateHz, int channels, int encoding) { + boolean previous = active; + boolean changed = setInputFormat(sampleRateHz, channels, encoding); + active = channels == 2 && (encoding == C.ENCODING_PCM_16BIT || encoding == C.ENCODING_PCM_FLOAT); + appliedMode = -1; + bytesPerSample = encoding == C.ENCODING_PCM_FLOAT ? 4 : 2; + return changed || previous != active; + } + @Override public boolean isActive() { return active; } + @Override public void queueInput(ByteBuffer input) { + ByteBuffer output = replaceOutputBuffer(input.remaining()); + int mode = KaraokePcmMixer.getMode(); + KaraokePcmMixer.mixStereo(input, output, bytesPerSample, mode); + appliedMode = mode; + output.flip(); + } +} diff --git a/karaoke-tests/ChannelMixerTest.java b/karaoke-tests/ChannelMixerTest.java new file mode 100644 index 0000000..4b06c4e --- /dev/null +++ b/karaoke-tests/ChannelMixerTest.java @@ -0,0 +1,30 @@ +import com.google.android.exoplayer2.audio.KaraokePcmMixer; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +public final class ChannelMixerTest { + public static void main(String[] args) { + verify16(0, new short[] {1000, -2000, 3000, -4000}); + verify16(1, new short[] {1000, 1000, 3000, 3000}); + verify16(2, new short[] {-2000, -2000, -4000, -4000}); + ByteBuffer floats = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putFloat(0.25f).putFloat(-0.5f); + floats.flip(); + ByteBuffer output = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN); + KaraokePcmMixer.mixStereo(floats, output, 4, 2); + output.flip(); + if (output.getFloat() != -0.5f || output.getFloat() != -0.5f) throw new AssertionError("float right channel"); + boolean rejected = false; + try { KaraokePcmMixer.setMode(3); } catch (IllegalArgumentException expected) { rejected = true; } + if (!rejected) throw new AssertionError("invalid mode accepted"); + System.out.println("PASS: stereo, left to both speakers, right to both speakers, float PCM, invalid mode"); + } + private static void verify16(int mode, short[] expected) { + ByteBuffer input = ByteBuffer.allocateDirect(8).order(ByteOrder.LITTLE_ENDIAN); + input.putShort((short)1000).putShort((short)-2000).putShort((short)3000).putShort((short)-4000).flip(); + ByteBuffer output = ByteBuffer.allocateDirect(8).order(ByteOrder.LITTLE_ENDIAN); + KaraokePcmMixer.mixStereo(input, output, 2, mode); + output.flip(); + for (short sample : expected) if (output.getShort() != sample) throw new AssertionError("mode " + mode); + if (input.hasRemaining() || output.hasRemaining()) throw new AssertionError("buffer consumption"); + } +}