/**
 * Copyright 2010-2024 JogAmp Community. All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without modification, are
 * permitted provided that the following conditions are met:
 *
 *    1. Redistributions of source code must retain the above copyright notice, this list of
 *       conditions and the following disclaimer.
 *
 *    2. Redistributions in binary form must reproduce the above copyright notice, this list
 *       of conditions and the following disclaimer in the documentation and/or other materials
 *       provided with the distribution.
 *
 * THIS SOFTWARE IS PROVIDED BY JogAmp Community ``AS IS'' AND ANY EXPRESS OR IMPLIED
 * WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND
 * FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL JogAmp Community OR
 * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
 * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
 * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
 * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
 * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 *
 * The views and conclusions contained in the software and documentation are those of the
 * authors and should not be interpreted as representing official policies, either expressed
 * or implied, of JogAmp Community.
 */
package com.jogamp.graph.ui.widgets;

import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.atomic.AtomicReference;

import com.jogamp.common.av.PTS;
import com.jogamp.common.net.Uri;
import com.jogamp.common.os.Clock;
import com.jogamp.common.util.InterruptSource;
import com.jogamp.graph.curve.Region;
import com.jogamp.graph.curve.opengl.GLRegion;
import com.jogamp.graph.font.Font;
import com.jogamp.graph.font.FontFactory;
import com.jogamp.graph.ui.Group;
import com.jogamp.graph.ui.Scene;
import com.jogamp.graph.ui.Shape;
import com.jogamp.graph.ui.TooltipText;
import com.jogamp.graph.ui.layout.Alignment;
import com.jogamp.graph.ui.layout.BoxLayout;
import com.jogamp.graph.ui.layout.Gap;
import com.jogamp.graph.ui.layout.GridLayout;
import com.jogamp.graph.ui.layout.Padding;
import com.jogamp.graph.ui.shapes.Button;
import com.jogamp.graph.ui.shapes.HUDShape;
import com.jogamp.graph.ui.shapes.Label;
import com.jogamp.graph.ui.shapes.MediaButton;
import com.jogamp.graph.ui.shapes.Rectangle;
import com.jogamp.math.FloatUtil;
import com.jogamp.math.Vec2f;
import com.jogamp.math.Vec3f;
import com.jogamp.math.Vec4f;
import com.jogamp.math.geom.AABBox;
import com.jogamp.newt.event.MouseEvent;
import com.jogamp.opengl.GL;
import com.jogamp.opengl.GL2ES2;
import com.jogamp.opengl.GLAnimatorControl;
import com.jogamp.opengl.GLAutoDrawable;
import com.jogamp.opengl.GLEventAdapter;
import com.jogamp.opengl.GLProfile;
import com.jogamp.opengl.util.av.GLMediaPlayer;
import com.jogamp.opengl.util.av.GLMediaPlayerFactory;
import com.jogamp.opengl.util.av.GLMediaPlayer.EventMask;
import com.jogamp.opengl.util.av.GLMediaPlayer.StreamException;
import com.jogamp.opengl.util.texture.TextureSequence;

import jogamp.graph.ui.TreeTool;

/**
 * Media player {@link Widget}, embedding a {@link MediaButton} and its controls.
 * @see #MediaPlayer(int, Scene, GLMediaPlayer, Uri, int, float, boolean, float, List)
 */
public class MediaPlayer extends Widget {
    private static final boolean DEBUG = false;
    public static final Vec2f FixedSymSize = new Vec2f(0.0f, 1.0f);
    public static final Vec2f SymSpacing = new Vec2f(0f, 0.2f);
    public static final float CtrlButtonWidth = 1f;
    public static final float CtrlButtonHeight = 1f;
    public static final Vec4f CtrlCellCol = new Vec4f(0, 0, 0, 0);
    private static final float BorderSz = 0.01f;
    private static final float BorderSzS = 0.03f;
    private static final Vec4f BorderColor = new Vec4f(0, 0, 0, 0.5f);
    private static final Vec4f BorderColorA = new Vec4f(0, 0, 0.5f, 0.5f);
    private static final float AlphaBlend = 0.3f;
    private static final float KnobScale = 2f;
    private static final float StillPlayerScale = 1/3f;
    private static final float ChapterTipScaleY = 5f;
    private static final float ToolTipScaleY = 0.6f;

    private final MediaButton mButton;

    /**
     * Constructs a {@link MediaPlayer}, i.e. its shapes and controls.
     * @param renderModes Graph's {@link Region} render modes, see {@link GLRegion#create(GLProfile, int, TextureSequence) create(..)}.
     * @param scene the used {@link Scene} to query parameter and access rendering loop
     * @param mPlayer fresh {@link GLMediaPlayer} instance owned by this {@link MediaPlayer}, may be customized via e.g. {@link GLMediaPlayer#setTextureMinMagFilter(int[])}.
     * @param medium {@link Uri} stream source, either a file or network source
     * @param aratio aspect ratio of the resulting {@link Shape}, usually 16.0f/9.0f or 4.0f/3.0f, which also denotes the width of this shape while using height 1.0.
     * @param letterBox toggles {@link GLMediaPlayer#setARatioLetterbox(boolean, Vec4f)} on or off
     * @param zoomSize zoom-size (0..1] for zoom-out control
     * @param enableStills pass {@code true} to enable still images on the time slider on mouse-over, involves a 2nd internal {@link GLMediaPlayer} instance
     * @param customCtrls optional custom controls, maybe an empty list
     */
    public MediaPlayer(final int renderModes, final Scene scene, final GLMediaPlayer mPlayer,
                       final Uri medium, final float aratio, final boolean letterBox, final float zoomSize,
                       final boolean enableStills, final List<Shape> customCtrls)
    {
        super( new BoxLayout(aratio, 1, Alignment.None) );

        final Font fontInfo = FontFactory.getDefaultFont(), fontSymbols = FontFactory.getSymbolsFont();
        if( null == fontInfo || null == fontSymbols ) {
            mButton = null;
            return;
        }
        final float zEpsilon = scene.getZEpsilon(16);
        final float superZOffset = zEpsilon * 20f;

        final int ctrlCellsInt = 11+3;
        final int ctrlCells = Math.max(customCtrls.size() + ctrlCellsInt, 20);

        final float ctrlCellWidth = (aratio-2*BorderSzS)/ctrlCells;
        final float ctrlCellHeight = ctrlCellWidth;
        final float ctrlSliderHeightMin = ctrlCellHeight/6f;       // bar-height
        final float ctrlSliderHeightMax = KnobScale * ctrlSliderHeightMin; // knob-height

        final AtomicReference<Shape> zoomReplacement = new AtomicReference<Shape>();
        final AtomicReference<Vec3f> zoomOrigScale = new AtomicReference<Vec3f>();
        final AtomicReference<Vec3f> zoomOrigPos = new AtomicReference<Vec3f>();

        this.setName("mp.container");
        this.setBorderColor(BorderColor).setBorder(BorderSz);
        this.setInteractive(true).setFixedARatioResize(true);

        mButton = new MediaButton(renderModes, aratio, 1, mPlayer);
        mButton.setName("mp.mButton").setInteractive(false);
        mButton.setPerp().setPressedColorMod(1f, 1f, 1f, 0.85f);
        mButton.setVerbose(false).addDefaultEventListener().setARatioLetterbox(letterBox, new Vec4f(1, 1, 1, 1));
        mPlayer.setAudioVolume( 0f );

        final RangeSlider ctrlSlider;
        {
            final float knobScale = ctrlSliderHeightMax / ctrlSliderHeightMin;
            ctrlSlider = new RangeSlider(renderModes, new Vec2f(aratio - ctrlSliderHeightMax, ctrlSliderHeightMin), knobScale, new Vec2f(0, 100), 1000, 0);
            final float dx = ctrlSliderHeightMax / 2f;
            final float dy = ( ctrlSliderHeightMax - ctrlSliderHeightMin ) * 0.5f;
            ctrlSlider.setPaddding(new Padding(0, dx, ctrlCellHeight-dy, dx));
            ctrlSlider.getBar().setColor(0.3f, 0.3f, 0.3f, 0.7f);
            ctrlSlider.getKnob().setColor(0.6f, 0.6f, 1f, 1f);
            ctrlSlider.setActiveKnobColorMod(new Vec4f(0.1f, 0.1f, 1, 1));
            ctrlSlider.move(0, 0, zEpsilon);
        }
        ctrlSlider.setName("mp.slider");

        final GLMediaPlayer stillPlayer;
        final Button stillTime;
        final HUDShape stillHUD;
        final Runnable reshapeStillHUD;
        {
            final Group stillGroup = new Group();
            final float labelW = aratio/4f;
            final float labelH = 1f/10f;
            stillTime = new Button(renderModes, fontInfo, PTS.toTimeStr(0), labelW, labelH, 0);
            stillTime.setName("mp.stillTime").setInteractive(false);
            stillTime.setLabelColor(0.2f, 0.2f, 0.2f, 1f);
            stillTime.setColor(1f, 1f, 1f, 1f);
            stillTime.setSpacing(0.1f, 0.3f);
            stillTime.setCorner(0.75f);
            stillTime.moveTo(aratio/2f-labelW/2f, 0, 0); // center to stillMedia
            stillGroup.addShape(stillTime);

            final MediaButton stillMedia;
            if( enableStills ) {
                stillPlayer = GLMediaPlayerFactory.createDefault();
                // stillPlayer.setTextureMinMagFilter( new int[] { GL.GL_NEAREST, GL.GL_NEAREST } );
                stillPlayer.setTextureMinMagFilter( new int[] { GL.GL_LINEAR, GL.GL_LINEAR } );
                stillPlayer.setTextureUnit(2);
                stillPlayer.addEventListener((final GLMediaPlayer mp, final EventMask eventMask, final long when) -> {
                    if( eventMask.isSet(GLMediaPlayer.EventMask.Bit.Play) ) {
                        mp.pause(true);
                    }
                });
                stillMedia = new MediaButton(renderModes, aratio, 1.0f, stillPlayer);
                stillMedia.setName("mp.stillMedia").setInteractive(false);
                stillMedia.setPerp().setPressedColorMod(1f, 1f, 1f, 0.85f);
                stillMedia.setVerbose(false).addDefaultEventListener().setARatioLetterbox(true, mButton.getARatioLetterboxBackColor());
                stillMedia.moveTo(0, labelH*1.2f, 0); // above stillTime
                stillGroup.addShape(stillMedia);
            } else {
                stillPlayer = null;
                stillMedia = null;
            }
            stillHUD = new HUDShape(scene,
                                    enableStills ? aratio*StillPlayerScale : labelW*StillPlayerScale,
                                    enableStills ? 1f*StillPlayerScale : labelH*StillPlayerScale,
                                    renderModes, ctrlSlider, stillGroup);
            stillHUD.setVisible(false);
            scene.addShape(stillHUD);
            reshapeStillHUD = () -> {
                final float ar = (float)mPlayer.getWidth()/(float)mPlayer.getHeight();
                final float labelW2 = ar/4f;
                final float labelH2 = 1f/10f;
                stillMedia.setSize(ar, 1f);
                stillMedia.moveTo(0, labelH2*1.2f, 0); // above stillTime
                stillTime.moveTo(ar/2f-labelW2/2f, 0, 0); // center to stillMedia
                stillHUD.setClientSize(ar*StillPlayerScale, 1f*StillPlayerScale);
            };
        }

        final Button playButton = new Button(renderModes, fontSymbols,
                fontSymbols.getUTF16String("play_arrow"),  fontSymbols.getUTF16String("pause"), CtrlButtonWidth, CtrlButtonHeight, zEpsilon);
        playButton.setName("mp.play");
        playButton.setSpacing(SymSpacing, FixedSymSize).setPerp().setColor(CtrlCellCol);

        final Button audioLabel = new Button(renderModes, fontInfo, "audio\nund", CtrlButtonWidth, CtrlButtonHeight, zEpsilon);
        audioLabel.setName("mp.audio_lang").setToggleable(false);
        audioLabel.setPerp().setColor(CtrlCellCol);
        final Button subLabel = new Button(renderModes, fontInfo, "sub\nund", CtrlButtonWidth, CtrlButtonHeight, zEpsilon);
        subLabel.setName("mp.sub_lang").setToggleable(false);
        subLabel.setPerp().setColor(CtrlCellCol);
        final Button cropButton = new Button(renderModes, fontSymbols,
                fontSymbols.getUTF16String("crop"), fontSymbols.getUTF16String("crop_free"), CtrlButtonWidth, CtrlButtonHeight, zEpsilon);
        cropButton.setSpacing(SymSpacing, FixedSymSize).setPerp().setColor(CtrlCellCol).setName("ar crop");

        mPlayer.addEventListener((final GLMediaPlayer mp, final EventMask eventMask, final long when) -> {
            if( DEBUG ) {
                System.err.println("MediaButton AttributesChanges: "+eventMask+", when "+when);
                System.err.println("MediaButton State: "+mp);
            }
            if( eventMask.isSet(GLMediaPlayer.EventMask.Bit.Init) ) {
                audioLabel.setText("audio\n"+mp.getLang(mp.getAID()));
                subLabel.setText("sub\n"+mp.getLang(mp.getSID()));
                ctrlSlider.setMinMax(new Vec2f(0, mp.getDuration()), 0);
                System.err.println("Init "+mp.toString());

                for(final GLMediaPlayer.Chapter c : mp.getChapters()) {
                    if( DEBUG ) {
                        System.err.println(c);
                    }
                    final Shape mark = ctrlSlider.addMark(c.start, new Vec4f(0.9f, 0.9f, 0.9f, 0.5f));
                    mark.setToolTip(new TooltipText(c.title+"\n@ "+PTS.toTimeStr(c.start, false)+", duration "+PTS.toTimeStr(c.duration(), false), fontInfo, ChapterTipScaleY));
                }
                final float aratioVideo = (float)mPlayer.getWidth() / (float)mPlayer.getHeight();
                if( FloatUtil.isZero(Math.abs(aratio - aratioVideo), 0.1f) ) {
                    cropButton.setVisible(false);
                    System.err.println("AR Crop disabled: aratioPlayer "+aratio+", aratioVideo "+aratioVideo+": "+mPlayer.getTitle());
                } else {
                    System.err.println("AR Crop  enabled: aratioPlayer "+aratio+", aratioVideo "+aratioVideo+": "+mPlayer.getTitle());
                }
                if( enableStills ) {
                    scene.invoke(false,  (final GLAutoDrawable d) -> {
                        stillPlayer.stop();
                        stillPlayer.playStream(mPlayer.getUri(), mPlayer.getVID(), GLMediaPlayer.STREAM_ID_NONE, GLMediaPlayer.STREAM_ID_NONE, 1);
                        reshapeStillHUD.run();
                        return true;
                    });
                }
            } else if( eventMask.isSet(GLMediaPlayer.EventMask.Bit.Play) ) {
                playButton.setToggle(true);
            } else if( eventMask.isSet(GLMediaPlayer.EventMask.Bit.Pause) ) {
                playButton.setToggle(false);
            }
            if( eventMask.isSet(GLMediaPlayer.EventMask.Bit.EOS) ) {
                final StreamException err = mp.getStreamException();
                if( null != err ) {
                    System.err.println("MovieSimple State: Exception: "+err.getMessage());
                } else {
                    new InterruptSource.Thread() {
                        @Override
                        public void run() {
                            mp.setPlaySpeed(1f);
                            mp.seek(0);
                            mp.resume();
                        }
                    }.start();
                }
            }
        } );
        this.addShape(mButton);

        Group ctrlGroup, infoGroup;
        Shape ctrlBlend;
        final Label muteLabel, infoLabel;
        final Button timeLabel;
        final float infoGroupHeight;
        final boolean[] hud_sticky = { false };
        final boolean[] info_full = { false };
        {
            muteLabel = new Label(renderModes, fontSymbols, aratio/6f, fontSymbols.getUTF16String("music_off")); // volume_mute, headset_off
            muteLabel.setName("mp.mute");
            {
                final float sz = aratio/6f;
                muteLabel.setColor(1, 0, 0, 1);
                muteLabel.setPaddding(new Padding(0, 0, 1f-sz, aratio-sz));

                muteLabel.setInteractive(false);
                muteLabel.setVisible( mPlayer.isAudioMuted() );
                this.addShape(muteLabel);
            }

            infoGroup = new Group(new BoxLayout());
            infoGroup.setName("mp.info").setInteractive(false);
            this.addShape( infoGroup.setVisible(false) );
            {
                final String text = "88:88 / 88:88:88 (88 %), playing (8.88x, vol 8.88), A/R 8.88, vid 8 (H264), aid 8 (eng, AC3), sid 8 (eng, HDMV_PGS)\n"+
                                    "JogAmp's GraphUI Full-Feature Media Player Widget Rocks!";
                final AABBox textBounds = fontInfo.getGlyphBounds(text);
                final float lineHeight = textBounds.getHeight()/2f; // fontInfo.getLineHeight();
                final float sxy = aratio/(1.1f*textBounds.getWidth()); // add 10%
                infoLabel = new Label(renderModes, fontInfo, text);
                infoLabel.setName("mp.info.label");
                infoLabel.setInteractive(false);
                infoLabel.setColor(1, 1, 1, 0.9f);
                infoLabel.scale(sxy, sxy, 1f);

                final float dy = 0.5f*lineHeight*sxy;
                infoGroupHeight = 2.5f*dy + textBounds.getHeight()*sxy;
                if( DEBUG ) {
                    System.err.println("XXX: sxy "+sxy+", b "+textBounds);
                    System.err.println("XXX: GroupHeight "+infoGroupHeight+", dy "+dy+", lineHeight*sxy "+(lineHeight*sxy));
                    System.err.println("XXX: b.getHeight() * sxy "+(textBounds.getHeight() * sxy));
                    System.err.println("XXX: (1f-GroupHeight+dy)/sxy "+((1f-infoGroupHeight+dy)/sxy));
                }
                infoLabel.setPaddding(new Padding(0, 0, (1f-infoGroupHeight+dy)/sxy, 0.5f));

                final Rectangle rect = new Rectangle(renderModes & ~Region.AA_RENDERING_MASK, aratio, infoGroupHeight, 0);
                rect.setName("mp.info.blend").setInteractive(false);
                rect.setColor(0, 0, 0, AlphaBlend);
                rect.setPaddding(new Padding(0, 0, 1f-infoGroupHeight, 0));

                infoGroup.addShape(rect);
                infoGroup.addShape(infoLabel);
            }
            {
                timeLabel = new Button(renderModes, fontInfo,
                        getMultilineTime(Clock.currentMillis(), mPlayer), CtrlButtonWidth, CtrlButtonHeight, zEpsilon);
                timeLabel.setName("mp.time");
                timeLabel.setPerp().setColor(CtrlCellCol);
                timeLabel.setLabelColor(1, 1, 1, 1.0f);
            }
            scene.addGLEventListener(new GLEventAdapter() {
                long t0 = 0;
                @Override
                public void display(final GLAutoDrawable drawable) {
                    final GLAnimatorControl anim = drawable.getAnimator();
                    if( ( timeLabel.isVisible() || infoLabel.isVisible() ) &&
                        ( mPlayer.getState() == GLMediaPlayer.State.Playing ||
                          mPlayer.getState() == GLMediaPlayer.State.Paused ) &&
                        null != anim )
                    {
                        final long t1 = anim.getTotalFPSDuration();
                        if( t1 - t0 >= 333) {
                            t0 = t1;
                            final int ptsMS = mPlayer.getPTS().getCurrent();
                            final int durationMS = mPlayer.getDuration();
                            infoLabel.setText(getInfo(ptsMS, durationMS, mPlayer, info_full[0]));
                            timeLabel.setText(getMultilineTime(ptsMS, durationMS));
                            ctrlSlider.setValue(ptsMS);
                        }
                    }
                }
            } );
            ctrlSlider.addChangeListener((final RangeSlider w, final float old_val, final float val, final float old_val_pct, final float val_pct, final Vec3f pos, final MouseEvent e) -> {
                if( DEBUG ) {
                    System.err.println("Dragged "+w.getName()+": "+PTS.toTimeStr(Math.round(val), true)+"ms, "+(val_pct*100f)+"%");
                    System.err.println("Slider.D "+ctrlSlider.getDescription());
                }
                final int dir_val = (int)Math.signum(val - old_val);
                final int currentPTS = mPlayer.getPTS().getCurrent();
                final int nextPTS = Math.round( val );
                final int dir_pts = (int)Math.signum(nextPTS - currentPTS);
                if( dir_val == dir_pts ) {
                    scene.invoke(false,  (final GLAutoDrawable d) -> {
                        final int durationMS = mPlayer.getDuration();
                        timeLabel.setText(getMultilineTime(nextPTS, durationMS));
                        mPlayer.seek(nextPTS);
                        return true;
                    } );
                }
            });

            final int[] lastPeekPTS = { 0 };
            ctrlSlider.addPeekListener((final RangeSlider w, final float val, final float val_pct, final Vec3f pos, final MouseEvent e) -> {
                final float res = Math.max(1000, w.getRange() / 300f); // ~300dpi alike less jittery
                final int nextPTS = Math.round( val/1000f ) * 1000;
                final Vec3f pos2 = new Vec3f(pos.x()-stillHUD.getClientSize().x()/2f, ctrlSliderHeightMax, pos.z() + ctrlSlider.getPosition().z());
                stillHUD.moveToHUDPos(pos2);
                // stillMedia.setARatioLetterbox(mButton.useARatioAdjustment(), mButton.getARatioLetterboxBackColor());
                stillTime.setText(PTS.toTimeStr(nextPTS, false));
                stillHUD.setVisible(true);
                if( enableStills && Math.abs(lastPeekPTS[0] - nextPTS ) >= res ) {
                    scene.invoke(false,  (final GLAutoDrawable d) -> {
                        stillPlayer.seek(nextPTS);
                        return true;
                    } );
                    lastPeekPTS[0] = nextPTS;
                }
            });
            ctrlSlider.addActivationListener((final Shape s) -> {
                if( s.isActive() ) {
                    // stillMedia.setARatioLetterbox(mButton.useARatioAdjustment(), mButton.getARatioLetterboxBackColor());
                    stillTime.setText(PTS.toTimeStr(mPlayer.getPTS().getCurrent(), false));
                    stillHUD.setVisible(true);
                } else {
                    stillHUD.setVisible(false);
                }
            });

            ctrlBlend = new Rectangle(renderModes & ~Region.AA_RENDERING_MASK, aratio, ctrlCellHeight, 0);
            ctrlBlend.setName("ctrl.blend").setInteractive(false);
            ctrlBlend.setColor(0, 0, 0, AlphaBlend);
            this.addShape( ctrlBlend.setVisible(false) );

            this.addShape( ctrlSlider.setVisible(false) );

            ctrlGroup = new Group(new GridLayout(ctrlCellWidth, ctrlCellHeight, Alignment.FillCenter, Gap.None, 1));
            ctrlGroup.setName("ctrlGroup").setInteractive(false);
            ctrlGroup.setPaddding(new Padding(0, BorderSzS, 0, BorderSzS));
            this.addShape( ctrlGroup.setVisible(false) );
            { // 1
                playButton.onToggle((final Shape s) -> {
                    if( s.isToggleOn() ) {
                        mPlayer.setPlaySpeed(1);
                        mPlayer.resume();
                    } else {
                        mPlayer.pause(false);
                    }
                });
                playButton.setToggle(true); // on == play
                ctrlGroup.addShape(playButton);
                playButton.setToolTip(new TooltipText("Play/Pause", fontInfo, ToolTipScaleY));
            }
            { // 2
                final Button button = new Button(renderModes, fontSymbols,
                        fontSymbols.getUTF16String("skip_previous"), CtrlButtonWidth, CtrlButtonHeight, zEpsilon);
                button.setName("back");
                button.setSpacing(SymSpacing, FixedSymSize).setPerp().setColor(CtrlCellCol);
                button.onClicked((final Shape s, final Vec3f pos, final MouseEvent e) -> {
                    final int pts = mPlayer.getPTS().getCurrent();
                    int targetMS = 0;
                    final GLMediaPlayer.Chapter c0 = mPlayer.getChapter(mPlayer.getPTS().getCurrent());
                    if( null != c0 ) {
                        if( pts > c0.start + 5000 ) {
                            targetMS = c0.start;
                        } else {
                            final GLMediaPlayer.Chapter c1 = mPlayer.getChapter(c0.start - 1);
                            if( null != c1 ) {
                                targetMS = c1.start;
                            } else {
                                targetMS = 0;
                            }
                        }
                    }
                    mPlayer.seek(targetMS);
                });
                ctrlGroup.addShape(button);
                button.setToolTip(new TooltipText("Prev Chapter", fontInfo, ToolTipScaleY));
            }
            { // 3
                final Button button = new Button(renderModes, fontSymbols,
                        fontSymbols.getUTF16String("skip_next"), CtrlButtonWidth, CtrlButtonHeight, zEpsilon);
                button.setName("next");
                button.setSpacing(SymSpacing, FixedSymSize).setPerp().setColor(CtrlCellCol);
                button.onClicked((final Shape s, final Vec3f pos, final MouseEvent e) -> {
                    int targetMS = 0;
                    final GLMediaPlayer.Chapter c0 = mPlayer.getChapter(mPlayer.getPTS().getCurrent());
                    if( null != c0 ) {
                        final GLMediaPlayer.Chapter c1 = mPlayer.getChapter(c0.end + 1);
                        if( null != c1 ) {
                            targetMS = c1.start;
                        } else {
                            targetMS = c0.end;
                        }
                    } else if( mPlayer.getChapters().length > 0 ) {
                        targetMS = 0;
                    } else {
                        targetMS = mPlayer.getPTS().getCurrent() * ( 1 + 1 / ( 60000 * 10 ) );
                    }
                    mPlayer.seek(targetMS);
                });
                ctrlGroup.addShape(button);
                button.setToolTip(new TooltipText("Next Chapter", fontInfo, ToolTipScaleY));
            }
            { // 4
                final Button button = new Button(renderModes, fontSymbols,
                        fontSymbols.getUTF16String("fast_rewind"), CtrlButtonWidth, CtrlButtonHeight, zEpsilon);
                button.setName("rv-");
                button.setSpacing(SymSpacing, FixedSymSize).setPerp().setColor(CtrlCellCol);
                button.onClicked((final Shape s, final Vec3f pos, final MouseEvent e) -> {
                    final float v = mPlayer.getPlaySpeed();
                    if( v <= 1.0f ) {
                        mPlayer.setPlaySpeed(v / 2.0f);
                    } else {
                        mPlayer.setPlaySpeed(v - 0.5f);
                    }
                });
                ctrlGroup.addShape(button);
                button.setToolTip(new TooltipText("replay speed: v <= 1 ? v/2 : v-0.5", fontInfo, ToolTipScaleY));
            }
            { // 5
                final Button button = new Button(renderModes, fontSymbols,
                        fontSymbols.getUTF16String("fast_forward"), CtrlButtonWidth, CtrlButtonHeight, zEpsilon);
                button.setName("rv+");
                button.setSpacing(SymSpacing, FixedSymSize).setPerp().setColor(CtrlCellCol);
                button.onClicked((final Shape s, final Vec3f pos, final MouseEvent e) -> {
                    final float v = mPlayer.getPlaySpeed();
                    if( 1f > v && v + 0.5f > 1f ) {
                        mPlayer.setPlaySpeed(1); // reset while crossing over 1
                    } else {
                        mPlayer.setPlaySpeed(v + 0.5f);
                    }
                });
                ctrlGroup.addShape(button);
                button.setToolTip(new TooltipText("replay speed: v+0.5", fontInfo, ToolTipScaleY));
            }
            { // 6
                final Button button = new Button(renderModes, fontSymbols,
                        fontSymbols.getUTF16String("replay_10"), CtrlButtonWidth, CtrlButtonHeight, zEpsilon);
                button.setName("rew5");
                button.setSpacing(SymSpacing, FixedSymSize).setPerp().setColor(CtrlCellCol);
                button.onClicked((final Shape s, final Vec3f pos, final MouseEvent e) -> {
                    mPlayer.seek(mPlayer.getPTS().getCurrent() - 10000);
                });
                button.addMouseListener(new Shape.MouseGestureAdapter() {
                    @Override
                    public void mouseWheelMoved(final MouseEvent e) {
                        final int pts0 = mPlayer.getPTS().getCurrent();
                        final int pts1 = pts0 + (int)(e.getRotation()[1]*10000f);
                        if( DEBUG ) {
                            System.err.println("Seek: "+pts0+" -> "+pts1);
                        }
                        mPlayer.seek(pts1);
                    } } );
                ctrlGroup.addShape(button);
                button.setToolTip(new TooltipText("Replay 10s (+scroll)", fontInfo, ToolTipScaleY));
            }
            { // 7
                final Button button = new Button(renderModes, fontSymbols,
                        fontSymbols.getUTF16String("forward_10"), CtrlButtonWidth, CtrlButtonHeight, zEpsilon);
                button.setName("fwd5");
                button.setSpacing(SymSpacing, FixedSymSize).setPerp().setColor(CtrlCellCol);
                button.onClicked((final Shape s, final Vec3f pos, final MouseEvent e) -> {
                    mPlayer.seek(mPlayer.getPTS().getCurrent() + 10000);
                });
                button.addMouseListener(new Shape.MouseGestureAdapter() {
                    @Override
                    public void mouseWheelMoved(final MouseEvent e) {
                        final int pts0 = mPlayer.getPTS().getCurrent();
                        final int pts1 = pts0 + (int)(e.getRotation()[1]*10000f);
                        if( DEBUG ) {
                            System.err.println("Seek: "+pts0+" -> "+pts1);
                        }
                        mPlayer.seek(pts1);
                    } } );
                ctrlGroup.addShape(button);
                button.setToolTip(new TooltipText("Forward 10s (+scroll)", fontInfo, ToolTipScaleY));
            }
            { // 8
                final Button button = new Button(renderModes, fontSymbols,
                        fontSymbols.getUTF16String("volume_up"), fontSymbols.getUTF16String("volume_mute"),  CtrlButtonWidth, CtrlButtonHeight, zEpsilon);
                button.setName("mute");
                button.setSpacing(SymSpacing, FixedSymSize).setPerp().setColor(CtrlCellCol);
                final float[] volume = { 1.0f };
                button.onToggle( (final Shape s) -> {
                    if( s.isToggleOn() ) {
                        mPlayer.setAudioVolume( volume[0] );
                    } else {
                        mPlayer.setAudioVolume( 0f );
                    }
                    muteLabel.setVisible( !s.isToggleOn() );
                });
                button.addMouseListener(new Shape.MouseGestureAdapter() {
                    @Override
                    public void mouseWheelMoved(final MouseEvent e) {
                        mPlayer.setAudioVolume( mPlayer.getAudioVolume() + e.getRotation()[1]/20f );
                        volume[0] = mPlayer.getAudioVolume();
                    } } );
                button.setToggle( !mPlayer.isAudioMuted() ); // on == volume
                ctrlGroup.addShape(button);
                button.setToolTip(new TooltipText("Volume (+scroll)", fontInfo, ToolTipScaleY));
            }
            { // 9
                audioLabel.onClicked((final Shape s, final Vec3f pos, final MouseEvent e) -> {
                    final int next_aid = mPlayer.getNextAID();
                    if( mPlayer.getAID() != next_aid ) {
                        mPlayer.switchStream(mPlayer.getVID(), next_aid, mPlayer.getSID());
                    }
                });
                ctrlGroup.addShape(audioLabel);
                audioLabel.setToolTip(new TooltipText("Audio Language", fontInfo, ToolTipScaleY));
            }
            { // 10
                subLabel.onClicked((final Shape s, final Vec3f pos, final MouseEvent e) -> {
                    final int next_sid = mPlayer.getNextSID();
                    if( mPlayer.getSID() != next_sid ) {
                        mPlayer.switchStream(mPlayer.getVID(), mPlayer.getAID(), next_sid);
                    }
                });
                ctrlGroup.addShape(subLabel);
                subLabel.setToolTip(new TooltipText("Subtitle Language", fontInfo, ToolTipScaleY));
            }
            { // 11
                ctrlGroup.addShape(timeLabel);
            }
            for(int i=11; i<ctrlCells-3-customCtrls.size(); ++i) {
                final Button button = new Button(renderModes, fontInfo, " ", CtrlButtonWidth, CtrlButtonHeight, zEpsilon);
                button.setName("ctrl_"+i);
                button.setSpacing(SymSpacing, FixedSymSize).setPerp().setColor(CtrlCellCol);
                ctrlGroup.addShape(button);
            }
            { // -1
                final Button button = new Button(renderModes, fontSymbols,
                        fontSymbols.getUTF16String("visibility"), fontSymbols.getUTF16String("visibility_off"),  CtrlButtonWidth, CtrlButtonHeight, zEpsilon);
                button.setName("hud");
                button.setSpacing(SymSpacing, FixedSymSize).setPerp().setColor(CtrlCellCol);
                button.onToggle( (final Shape s) -> {
                    hud_sticky[0] = s.isToggleOn();
                });
                button.setToggle( false );
                ctrlGroup.addShape(button);
                button.setToolTip(new TooltipText("Sticky HUD", fontInfo, ToolTipScaleY));
            }
            { // -2
                final boolean[] value = { !letterBox };
                cropButton.onToggle( (final Shape s) -> {
                    value[0] = !value[0];
                    mButton.setARatioLetterbox(!value[0], mButton.getARatioLetterboxBackColor());
                });
                ctrlGroup.addShape(cropButton);
                cropButton.setToolTip(new TooltipText("Letterbox Crop", fontInfo, ToolTipScaleY));
            }
            { // -3
                final Button button = new Button(renderModes, fontSymbols,
                        fontSymbols.getUTF16String("zoom_out_map"), fontSymbols.getUTF16String("zoom_in_map"),  CtrlButtonWidth, CtrlButtonHeight, zEpsilon);
                button.setName("zoom");
                button.setSpacing(SymSpacing, FixedSymSize).setPerp().setColor(CtrlCellCol);
                final boolean fullScene = FloatUtil.isEqual(1f, zoomSize);
                final boolean wasDraggable = isDraggable();
                button.onToggle( (final Shape s) -> {
                    if( s.isToggleOn() ) {
                        // Zoom in
                        if( fullScene ) {
                            MediaPlayer.this.setBorder(0f);
                            MediaPlayer.this.setDraggable(false);
                        }
                        final AABBox sbox = scene.getBounds();
                        final Group parent = this.getParent();
                        final float sx = sbox.getWidth() * zoomSize / this.getScaledWidth();
                        final float sy = sbox.getHeight() * zoomSize / this.getScaledHeight();
                        final float sxy = Math.min(sx, sy);
                        Shape _zoomReplacement = null;
                        zoomOrigScale.set( this.getScale().copy() );
                        zoomOrigPos.set( this.getPosition().copy() );
                        if( null != parent ) {
                            // System.err.println("Zoom1: rep, sxy "+sx+" x "+sy+" = "+sxy);
                            _zoomReplacement = new Label(renderModes, fontInfo, aratio/40f, "zoomed");
                            final boolean r = parent.replaceShape(this, _zoomReplacement);
                            if( r ) {
                                // System.err.println("Zoom1: p "+parent);
                                // System.err.println("Zoom1: t "+this);
                                this.scale(sxy, sxy, 1f);
                                this.moveTo(sbox.getLow()).move(sbox.getWidth() * ( 1f - zoomSize )/2.0f, sbox.getHeight() * ( 1f - zoomSize )/2.0f, superZOffset);
                                scene.addShape(this);
                            } else {
                                // System.err.println("Zoom1: failed "+this);
                            }
                        } else {
                            // System.err.println("Zoom2: top, sxy "+sx+" x "+sy+" = "+sxy);
                            // System.err.println("Zoom2: t "+this);
                            this.scale(sxy, sxy, 1f);
                            this.moveTo(sbox.getLow()).move(sbox.getWidth() * ( 1f - zoomSize )/2.0f, sbox.getHeight() * ( 1f - zoomSize )/2.0f, superZOffset);
                        }
                        // System.err.println("Zoom: R "+_zoomReplacement);
                        zoomReplacement.set( _zoomReplacement );
                    } else {
                        // Zoom out
                        if( fullScene ) {
                            MediaPlayer.this.setBorder(BorderSz);
                            MediaPlayer.this.setDraggable(wasDraggable);
                        }
                        final Vec3f _zoomOrigScale = zoomOrigScale.getAndSet(null);
                        final Vec3f _zoomOrigPos = zoomOrigPos.getAndSet(null);
                        final Shape _zoomReplacement = zoomReplacement.getAndSet(null);
                        if( null != _zoomReplacement ) {
                            final Group parent = _zoomReplacement.getParent();
                            scene.removeShape(this);
                            if( null != _zoomOrigScale ) {
                                this.setScale(_zoomOrigScale);
                            }
                            if( null != _zoomOrigPos ) {
                                this.moveTo(_zoomOrigPos);
                            }
                            parent.replaceShape(_zoomReplacement, this);
                            // System.err.println("Reset1: "+parent);
                        } else {
                            if( null != _zoomOrigScale ) {
                                this.setScale(_zoomOrigScale);
                            }
                            if( null != _zoomOrigPos ) {
                                this.moveTo(_zoomOrigPos);
                            }
                            // System.err.println("Reset2: top");
                        }
                        if( null != _zoomReplacement ) {
                            scene.invoke(true, (drawable) -> {
                                final GL2ES2 gl = drawable.getGL().getGL2ES2();
                                _zoomReplacement .destroy(gl, scene.getRenderer());
                                return true;
                            });
                        }
                    }
                });
                button.setToggle( false ); // on == zoom
                ctrlGroup.addShape(button);
                button.setToolTip(new TooltipText("Zoom", fontInfo, ToolTipScaleY));
            }
            for(final Shape cs : customCtrls ) {
                ctrlGroup.addShape(cs);
            }
        }
        this.enableTopLevelWidget(scene);

        this.addActivationListener( (final Shape s) -> {
            if( this.isActive() ) {
                this.setBorderColor(BorderColorA);
            } else {
                final boolean hud_on = hud_sticky[0];
                this.setBorderColor(BorderColor);
                ctrlSlider.setVisible(hud_on);
                ctrlBlend.setVisible(hud_on);
                ctrlGroup.setVisible(hud_on);
                infoGroup.setVisible(hud_on);
            }
        });
        this.addMouseListener(new Shape.MouseGestureAdapter() {
            @Override
            public void mouseClicked(final MouseEvent e) {
                final Shape.EventInfo shapeEvent = (Shape.EventInfo) e.getAttachment();
                final Vec3f p = shapeEvent.objPos;
                if( p.y() > ( 1f - infoGroupHeight ) ) {
                    info_full[0] = !info_full[0];
                    final float sxy = infoLabel.getScale().x();
                    final float p_bottom_s = infoLabel.getPadding().bottom * sxy;
                    final float sxy2;
                    if( info_full[0] ) {
                        sxy2 = sxy * 0.5f;
                    } else {
                        sxy2 = sxy * 2f;
                    }
                    infoLabel.setScale(sxy2,  sxy2,  1f);
                    infoLabel.setPaddding(new Padding(0, 0, p_bottom_s/sxy2, 0.5f));
                }
            }
            @Override
            public void mouseMoved(final MouseEvent e) {
                final Shape.EventInfo shapeEvent = (Shape.EventInfo) e.getAttachment();
                final Vec3f p = shapeEvent.objPos;
                final boolean c = hud_sticky[0] || ( ctrlCellHeight + ctrlSliderHeightMax ) > p.y() || p.y() > ( 1f - infoGroupHeight );
                ctrlSlider.setVisible(c);
                ctrlBlend.setVisible(c);
                ctrlGroup.setVisible(c);
                infoGroup.setVisible(c);
            }
            @Override
            public void mouseReleased(final MouseEvent e) {
                mButton.setPressedColorMod(1f, 1f, 1f, 1f);
            }
            @Override
            public void mouseDragged(final MouseEvent e) {
                mButton.setPressedColorMod(1f, 1f, 1f, 0.85f);
            }
        } );
        TreeTool.forAll(this, (final Shape s) -> { s.setDraggable(false).setResizable(false); return false; });
        ctrlSlider.getKnob().setDraggable(true);
    }

    /** Toggle enabling subtitle rendering */
    public void setSubtitlesEnabled(final boolean v) {
        if( null != mButton ) {
            mButton.setSubtitlesEnabled(v);
        }
    }

    /**
     * Sets text/ASS subtitle parameter, enabling subtitle rendering
     * @param subFont text/ASS subtitle font, pass {@code null} for {@link FontFactory#getDefaultFont()}
     *                {@link FontFactory#getFallbackFont()} is used {@link Font#getBestCoverage(Font, Font, CharSequence) if providing a better coverage} of a Text/ASS subtitle line.
     * @param subLineHeightPct text/ASS subtitle line height percentage, defaults to {@link MediaButton#DEFAULT_ASS_SUB_HEIGHT}
     * @param subLineDY text/ASS y-axis offset to bottom in line-height, defaults to {@link MediaButton#DEFAULT_ASS_SUB_POS}
     * @param subAlignment text/ASS subtitle alignment, defaults to {@link #DEFAULT_ASS_SUB_ALIGNMENT}
     */
    public void setSubtitleParams(final Font subFont, final float subLineHeightPct, final float subLineDY, final Alignment subAlignment) {
        if( null != mButton ) {
            mButton.setSubtitleParams(subFont, subLineHeightPct, subLineDY, subAlignment);
        }
    }
    /**
     * Sets text/ASS subtitle colors
     * @param color color for the text/ASS, defaults to {@link MediaButton#DEFAULT_ASS_SUB_COLOR}
     * @param blend blending alpha (darkness), defaults to {@link MediaButton#DEFAULT_ASS_SUB_BLEND}
     */
    public void setSubtitleColor(final Vec4f color, final float blend) {
        if( null != mButton ) {
            mButton.setSubtitleColor(color, blend);
        }
    }

    public static String getInfo(final long currentMillis, final GLMediaPlayer mPlayer, final boolean full) {
        return getInfo(mPlayer.getPTS().get(currentMillis), mPlayer.getDuration(), mPlayer, full);
    }
    public static String getInfo(final int ptsMS, final int durationMS, final GLMediaPlayer mPlayer, final boolean full) {
        final String chapter;
        {
            final GLMediaPlayer.Chapter c = mPlayer.getChapter(ptsMS);
            if( null != c ) {
                chapter = " - "+c.title;
            } else {
                chapter = "";
            }
        }
        final float aspect = (float)mPlayer.getWidth() / (float)mPlayer.getHeight();
        final float pct = (float)ptsMS / (float)durationMS;
        if( full ) {
            final String text1 = String.format("%s / %s (%.0f %%), %s (%01.2fx, vol %1.2f), A/R %.2f, fps %02.1f, kbps %.2f",
                    PTS.toTimeStr(ptsMS, false), PTS.toTimeStr(durationMS, false), pct*100,
                    mPlayer.getState().toString().toLowerCase(), mPlayer.getPlaySpeed(), mPlayer.getAudioVolume(), aspect,
                    mPlayer.getFramerate(), mPlayer.getStreamBitrate()/1000.0f);
            final String text2 = String.format("video: id %d (%s), kbps %.2f, codec %s/'%s'",
                    mPlayer.getVID(), mPlayer.getLang(mPlayer.getVID()), mPlayer.getVideoBitrate()/1000.0f, mPlayer.getVideoCodecID(), mPlayer.getVideoCodec());
            final String text3 = String.format("audio: id %d/%s (%s/%s), kbps %.2f, codec %s/'%s'",
                    mPlayer.getAID(), Arrays.toString(mPlayer.getAStreams()),
                    mPlayer.getLang(mPlayer.getAID()), Arrays.toString(mPlayer.getALangs()),
                    mPlayer.getAudioBitrate()/1000.0f, mPlayer.getAudioCodecID(), mPlayer.getAudioCodec());
            final String text4 = String.format("sub  : id %d/%s (%s/%s), codec %s/'%s'",
                    mPlayer.getSID(), Arrays.toString(mPlayer.getSStreams()),
                    mPlayer.getLang(mPlayer.getSID()), Arrays.toString(mPlayer.getSLangs()),
                    mPlayer.getSubtitleCodecID(), mPlayer.getSubtitleCodec());
            return text1+"\n"+text2+"\n"+text3+"\n"+text4+"\n"+mPlayer.getTitle()+chapter;
        } else {
            final String vinfo, ainfo, sinfo;
            if( mPlayer.getVID() != GLMediaPlayer.STREAM_ID_NONE ) {
                vinfo = String.format((Locale)null, ", vid %d (%s)", mPlayer.getVID(), mPlayer.getVideoCodecID());
            } else {
                vinfo = "";
            }
            if( mPlayer.getAID() != GLMediaPlayer.STREAM_ID_NONE ) {
                ainfo = String.format((Locale)null, ", aid %d (%s, %s)", mPlayer.getAID(), mPlayer.getLang(mPlayer.getAID()), mPlayer.getAudioCodecID());
            } else {
                ainfo = "";
            }
            if( mPlayer.getSID() != GLMediaPlayer.STREAM_ID_NONE ) {
                sinfo = String.format((Locale)null, ", sid %d (%s, %s)", mPlayer.getSID(), mPlayer.getLang(mPlayer.getSID()), mPlayer.getSubtitleCodecID());
            } else {
                sinfo = "";
            }
            final String text1 = String.format("%s / %s (%.0f %%), %s (%01.2fx, vol %1.2f), A/R %.2f%s%s%s",
                    PTS.toTimeStr(ptsMS, false), PTS.toTimeStr(durationMS, false), pct*100,
                    mPlayer.getState().toString().toLowerCase(), mPlayer.getPlaySpeed(), mPlayer.getAudioVolume(), aspect, vinfo, ainfo, sinfo);
            return text1+"\n"+mPlayer.getTitle()+chapter;
        }
    }
    public static String getMultilineTime(final long currentMillis, final GLMediaPlayer mPlayer) {
        return getMultilineTime(mPlayer.getPTS().get(currentMillis), mPlayer.getDuration());
    }
    public static String getMultilineTime(final int ptsMS, final int durationMS) {
        final float pct = (float)ptsMS / (float)durationMS;
        return String.format("%.0f %%%n%s%n%s",
                    pct*100, PTS.toTimeStr(ptsMS, false), PTS.toTimeStr(durationMS, false));
    }
}