/** * Copyright 2014-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.shapes; import com.jogamp.opengl.GL; import com.jogamp.opengl.GL2ES2; import com.jogamp.opengl.GLProfile; import java.util.ArrayList; import java.util.List; import com.jogamp.common.av.AudioSink; import com.jogamp.common.util.InterruptSource; import com.jogamp.common.util.StringUtil; import com.jogamp.graph.curve.Region; import com.jogamp.graph.curve.opengl.RegionRenderer; import com.jogamp.graph.font.Font; import com.jogamp.graph.font.FontFactory; import com.jogamp.graph.ui.GraphShape; import com.jogamp.graph.ui.layout.Alignment; import com.jogamp.math.Vec2f; import com.jogamp.math.Vec4f; import com.jogamp.math.geom.AABBox; import com.jogamp.math.util.PMVMatrix4f; import com.jogamp.opengl.util.av.SubTextEvent; import com.jogamp.opengl.util.av.SubBitmapEvent; import com.jogamp.opengl.util.av.SubtitleEvent; import com.jogamp.opengl.util.av.SubtitleEventListener; import com.jogamp.opengl.util.texture.ImageSequence; import com.jogamp.opengl.util.av.CodecID; import com.jogamp.opengl.util.av.GLMediaPlayer; import com.jogamp.opengl.util.av.GLMediaPlayer.GLMediaEventListener; import com.jogamp.opengl.util.av.GLMediaPlayer.StreamException; /** * A GraphUI {@link GLMediaPlayer} based {@link TexSeqButton} {@link GraphShape}. *

* GraphUI is GPU based and resolution independent. *

*

* This button is rendered with a round oval shape. * To render it rectangular, {@link #setCorner(float)} to zero. *

*

* Default colors (toggle-on is full color): * - non-toggle: 1 * color * - pressed: 0.9 * color * - toggle-off: 0.8 * color * - toggle-on: 1.0 * color *

*/ public class MediaButton extends TexSeqButton { private static final boolean DEBUG_SUB = false; private static final boolean DEBUG_SUB_LAYOUT = false; private boolean verbose = false; private Font subFont; private Font subFallbackFont; private final Label subLabel; private final float subZOffset; private boolean subEnabled; private float subLineHeightPct; private float subLineDY; private Alignment subAlignment; private final Rectangle subBlend; private final ImageButton subTexImg; /** Default text/ASS subtitle line height percentage, {@value}. */ public static final float DEFAULT_ASS_SUB_HEIGHT = 0.075f; /** Default text/ASS subtitle y-axis offset to bottom in line-height percentage, {@value}. */ public static final float DEFAULT_ASS_SUB_POS = 0.25f; /** Default color for the text/ASS subtitles, defaults to RGBA {@code 1, 1, 1, 1}. */ public static final Vec4f DEFAULT_ASS_SUB_COLOR = new Vec4f( 1, 1, 1, 1 ); /** Default blending alpha (darkness) for the text/ASS subtitles, defaults to {@value}. */ public static final float DEFAULT_ASS_SUB_BLEND = 0.3f; /** Default text/ASS subtitle alignment, defaults to {@link Alignment#CenterHoriz}. */ public static final Alignment DEFAULT_ASS_SUB_ALIGNMENT = Alignment.CenterHoriz; private static final float ASS_SUB_USED_WIDTH = 0.90f; private static final float ASS_SUB_BLEND_ADDED_HEIGHT = 0.25f; private static final int ASS_SUB_MAX_SPLIT_LINES = 4; private SubtitleEvent drawLastSub; private final List subEventQueue = new ArrayList(); private final Object subEventLock = new Object(); /** Constructs a {@link MediaButton} with {@link FontFactory#getDefaultFont()} for subtitles. */ public MediaButton(final int renderModes, final float width, final float height, final GLMediaPlayer mPlayer) { this(renderModes, width, height, mPlayer, null); } /** * Constructs a {@link MediaButton} prepared for using subtitles * @param renderModes * @param width * @param height * @param mPlayer * @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. * @see #setSubtitleParams(Font, float, float, Alignment) * @see #setSubtitleColor(Vec4f, float) */ public MediaButton(final int renderModes, final float width, final float height, final GLMediaPlayer mPlayer, final Font subFont) { super(renderModes & ~Region.AA_RENDERING_MASK, width, height, mPlayer); setColor(1.0f, 1.0f, 1.0f, 0.0f); setPressedColorMod(0.9f, 0.9f, 0.9f, 0.7f); setToggleOffColorMod(0.8f, 0.8f, 0.8f, 1.0f); setToggleOnColorMod(1.0f, 1.0f, 1.0f, 1.0f); mPlayer.setSubtitleEventListener(subEventListener); setSubtitleParams(subFont, DEFAULT_ASS_SUB_HEIGHT, DEFAULT_ASS_SUB_POS, DEFAULT_ASS_SUB_ALIGNMENT); this.subLabel = new Label(renderModes, this.subFont, ""); this.subZOffset = Button.DEFAULT_LABEL_ZOFFSET; this.subLabel.moveTo(0, 0, subZOffset); this.subBlend = new Rectangle(renderModes, 1f, 1f, 0f); setSubtitleColor(DEFAULT_ASS_SUB_COLOR, DEFAULT_ASS_SUB_BLEND); this.subTexImg = new ImageButton(renderModes, width, height, new ImageSequence(mPlayer.getTextureUnit(), true /* useBuildInTexLookup */)); this.subTexImg.setPerp().setToggleable(false).setDragAndResizable(false).setInteractive(false); // this.subTexImg.setBorder(0.001f).setBorderColor(1, 1, 0, 1); this.subTexImg.getImageSequence().setParams(GL.GL_LINEAR, GL.GL_LINEAR, GL.GL_CLAMP_TO_EDGE, GL.GL_CLAMP_TO_EDGE); this.subTexImg.setARatioAdjustment(false); this.drawLastSub = null; } /** Toggle enabling subtitle rendering */ public void setSubtitlesEnabled(final boolean v) { subEnabled = 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 #DEFAULT_ASS_SUB_HEIGHT} * @param subLineDY text/ASS y-axis offset to bottom in line-height, defaults to {@link #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 != subFont ) { this.subFont = subFont; } else { this.subFont = FontFactory.getDefaultFont(); } this.subFallbackFont = FontFactory.getFallbackFont(); this.subLineHeightPct = subLineHeightPct; this.subLineDY = subLineDY; this.subAlignment = subAlignment; this.subEnabled = true; } /** * Sets text/ASS subtitle colors * @param color color for the text/ASS, defaults to {@link #DEFAULT_ASS_SUB_COLOR} * @param blend blending alpha (darkness), defaults to {@link #DEFAULT_ASS_SUB_BLEND} */ public void setSubtitleColor(final Vec4f color, final float blend) { this.subLabel.setColor( color ); this.subBlend.setColor( 0, 0, 0, blend ); } public final SubtitleEventListener getSubEventListener() { return subEventListener; } private final SubtitleEventListener subEventListener = new SubtitleEventListener() { @Override public void run(final SubtitleEvent e) { synchronized( subEventLock ) { subEventQueue.add(e); if( DEBUG_SUB ) { System.err.println("MediaButton: GOT #"+subEventQueue.size()+": "+e); } } } }; public MediaButton setVerbose(final boolean v) { verbose = v; return this; } /** * Add the default {@link GLMediaEventListener} to {@link #getGLMediaPlayer() this class's GLMediaPlayer}. */ public MediaButton addDefaultEventListener() { getGLMediaPlayer().addEventListener(defGLMediaEventListener); return this; } public final GLMediaPlayer getGLMediaPlayer() { return (GLMediaPlayer)texSeq; } public final AudioSink getAudioSink() { return getGLMediaPlayer().getAudioSink(); } private final GLMediaEventListener defGLMediaEventListener = new GLMediaEventListener() { @Override public void attributesChanged(final GLMediaPlayer mp, final GLMediaPlayer.EventMask eventMask, final long when) { if( verbose ) { System.err.println("MediaButton AttributesChanges: "+eventMask+", when "+when); System.err.println("MediaButton State: "+mp); } if( eventMask.isSet(GLMediaPlayer.EventMask.Bit.Init) ) { clearSubtitleCache(); resetGL = true; } else if( eventMask.isSet(GLMediaPlayer.EventMask.Bit.Uninit) || eventMask.isSet(GLMediaPlayer.EventMask.Bit.Play) || eventMask.isSet(GLMediaPlayer.EventMask.Bit.Seek) || eventMask.isSet(GLMediaPlayer.EventMask.Bit.SID) ) { clearSubtitleCache(); markStateDirty(); } else if( eventMask.isSet(GLMediaPlayer.EventMask.Bit.Pause) ) { clearSubtitleCacheButLast(); markStateDirty(); } if( eventMask.isSet(GLMediaPlayer.EventMask.Bit.Size) ) { // FIXME: mPlayer.resetGLState(); } if( eventMask.isSet(GLMediaPlayer.EventMask.Bit.EOS) ) { new InterruptSource.Thread() { @Override public void run() { // loop for-ever .. mp.seek(0); mp.resume(); } }.start(); } else if( eventMask.isSet(GLMediaPlayer.EventMask.Bit.Error) ) { final StreamException se = mp.getStreamException(); if( null != se ) { se.printStackTrace(); } } } }; @Override protected void clearImpl(final GL2ES2 gl, final RegionRenderer renderer) { ((GLMediaPlayer)texSeq).stop(); ((GLMediaPlayer)texSeq).seek(0); clearSubtitleCache(); } @Override protected void destroyImpl(final GL2ES2 gl, final RegionRenderer renderer) { ((GLMediaPlayer)texSeq).stop(); clearSubtitleCache(); subTexImg.destroy(gl, renderer); subLabel.destroy(gl, renderer); subBlend.destroy(gl, renderer); ((GLMediaPlayer)texSeq).destroy(gl); } volatile boolean resetGL = true; @Override protected void addShapeToRegion(final GLProfile glp, final GL2ES2 gl) { super.addShapeToRegion(glp, gl); } @Override protected final void drawImpl0(final GL2ES2 gl, final RegionRenderer renderer, final Vec4f rgba) { final GLMediaPlayer mPlayer = (GLMediaPlayer)texSeq; if( resetGL ) { resetGL = false; try { clearSubtitleCache(); mPlayer.initGL(gl); if( null != region ) { region.markShapeDirty(); // reset texture data } } catch (final Exception e) { e.printStackTrace(); } } super.drawImpl0(gl, renderer, rgba); if( subEnabled && GLMediaPlayer.STREAM_ID_NONE != mPlayer.getSID() ) { drawSubtitle(gl, renderer); } if( GLMediaPlayer.State.Playing == mPlayer.getState() ) { markStateDirty(); // keep on going } }; private final void clearSubtitleCache() { synchronized( subEventLock ) { final SubtitleEvent lastSub = drawLastSub; drawLastSub = null; if( null != lastSub ) { lastSub.release(); } subTexImg.getImageSequence().removeAllFrames(); for(final SubtitleEvent e : subEventQueue) { e.release(); } subEventQueue.clear(); if( DEBUG_SUB ) { System.err.println("MediaButton.clearSubtitleCache: "+subEventQueue.size()+", last "+lastSub); } } } private final void clearSubtitleCacheButLast() { synchronized( subEventLock ) { final SubtitleEvent lastSub = drawLastSub; for(int i=subEventQueue.size()-1; i>=0; --i) { final SubtitleEvent e = subEventQueue.get(i); if( lastSub != e ) { e.release(); subEventQueue.remove(i); } } if( DEBUG_SUB ) { System.err.println("MediaButton.clearSubtitleCacheButLast: "+subEventQueue.size()+", last "+lastSub); } } } private final void drawSubtitle(final GL2ES2 gl, final RegionRenderer renderer) { final GLMediaPlayer mPlayer = (GLMediaPlayer)texSeq; final int pts = mPlayer.getPTS().getCurrent(); // Validate draw_lastSub timeout SubtitleEvent lastSub = drawLastSub; { if( null != lastSub && lastSub.pts_end < pts ) { if( DEBUG_SUB ) { System.err.println("MediaButton: Drop.0: pts "+pts+", "+lastSub); } drawLastSub = null; lastSub.release(); lastSub = null; } } // Dequeue and earmark new subtitle in time. // A new subtitle (empty as well) may simply replace an older one, // allowing PGS subtitles to work (infinite end-time) final SubtitleEvent sub; final boolean newSub; { final SubtitleEvent gotSub; synchronized( subEventLock ) { if( subEventQueue.size() > 0 ) { final SubtitleEvent e = subEventQueue.get(0); if( e.pts_start <= pts && pts <= e.pts_end ) { gotSub = e; subEventQueue.remove(0); } else if( e.pts_end < pts ) { gotSub = null; subEventQueue.remove(0); e.release(); if( DEBUG_SUB ) { System.err.println("MediaButton: Drop.1: pts "+pts+", "+e); } } else { // subtitle for the future, keep it gotSub = null; } } else { gotSub = null; } } if( null == gotSub ) { sub = lastSub; newSub = false; } else { if( null != lastSub ) { lastSub.release(); } lastSub = null; if( SubtitleEvent.Type.Empty == gotSub.type ) { gotSub.release(); sub = null; newSub = false; if( DEBUG_SUB ) { System.err.println("MediaButton: Empty: pts "+pts+", "+gotSub); } } else { drawLastSub = gotSub; sub = gotSub; newSub = true; } } } // drop or draw (update label for new subtitle) if( null == sub ) { drawLastSub = null; } else if( SubtitleEvent.Type.Text == sub.type ) { final SubTextEvent assSub = (SubTextEvent)sub; if( newSub ) { final float maxWidth = box.getWidth() * ASS_SUB_USED_WIDTH; subLabel.setFont( Font.getBestCoverage(subFont, subFallbackFont, assSub.text) ); subLabel.setText(assSub.text); int lines = assSub.lines; AABBox subBox = subLabel.getBounds(gl.getGLProfile()); float subLineHeight = subBox.getHeight() / lines; float lineHeight = box.getHeight() * subLineHeightPct; float s_s = lineHeight / subLineHeight; if( s_s * subBox.getWidth() > maxWidth ) { // Split too wide text into multiple lines (max 4) fitting into box // while trimming it beforehand. if( DEBUG_SUB_LAYOUT ) { System.err.println("XXX split.0 has lines "+lines+", s "+s_s+", width "+(s_s * subBox.getWidth())+" > "+maxWidth+": "+assSub.text); } final String trimmed = StringUtil.trim(assSub.text, StringUtil.WHITESPACE, " "); lines = Math.min(ASS_SUB_MAX_SPLIT_LINES, (int)Math.ceil( s_s * subBox.getWidth() / maxWidth )); final String text = StringUtil.split(trimmed, lines, " ", String.valueOf(StringUtil.LF)); lines = StringUtil.getLineCount(text); subLabel.setText(text); subBox = subLabel.getBounds(gl.getGLProfile()); subLineHeight = subBox.getHeight() / lines; lineHeight = box.getHeight() * subLineHeightPct; s_s = lineHeight / subLineHeight; if( DEBUG_SUB_LAYOUT ) { System.err.println("XXX split.X to lines "+lines+", s "+s_s+", width "+(s_s * subBox.getWidth())+" <=?= "+maxWidth+": "+text); } if( s_s * subBox.getWidth() > maxWidth ) { // scale down remaining diff s_s = maxWidth / subBox.getWidth(); lineHeight *= s_s / ( lineHeight / subLineHeight ); if( DEBUG_SUB_LAYOUT ) { System.err.println("XXX scale-down scale "+s_s+", width "+(s_s * subBox.getWidth())+" <= "+maxWidth+": "+text); } } } subLabel.setScale(s_s, s_s, 1); final float labelHeight = lineHeight * lines; final float blendHeight = labelHeight + lineHeight * ASS_SUB_BLEND_ADDED_HEIGHT; final Vec2f v_sz = new Vec2f(mPlayer.getWidth(), mPlayer.getHeight()); final Vec2f v_sxy = new Vec2f( box.getWidth(), box.getHeight() ).div( v_sz ); final float v_s = Math.min( v_sxy.x(), v_sxy.y() ); final Vec2f v_ctr = new Vec2f(v_sz).scale(0.5f); // original video size center final Vec2f b_ctr = new Vec2f(box.getCenter()).scale(1/v_s); final float d_bl = ( blendHeight - labelHeight ) * 0.5f; final float v_maxWidth = v_sz.x() * ASS_SUB_USED_WIDTH; final float d_vmw = v_sz.x() - v_maxWidth; final Vec2f s_p0_s; if( subAlignment.isSet( Alignment.Bit.Left) ) { // Alignment.Bit.Left final Vec2f s_p0 = new Vec2f( d_vmw*0.5f, ( subLineHeight * subLineDY * s_s ) / v_s); s_p0_s = s_p0.sub( v_ctr ).add(b_ctr).scale( v_s ).add(0, d_bl); } else { // Alignment.Bit.CenterHoriz final Vec2f s_p0 = new Vec2f( d_vmw*0.5f + ( v_maxWidth - subBox.getWidth()*s_s/v_s )*0.5f, ( subLineHeight * subLineDY * s_s ) / v_s); s_p0_s = s_p0.sub( v_ctr ).add(b_ctr).scale( v_s ).add(0, d_bl); } subLabel.moveTo(s_p0_s.x(), s_p0_s.y(), 2*subZOffset); subBlend.setDimension(box.getWidth(), blendHeight, 0f); subBlend.setPosition(0, s_p0_s.y() - d_bl, 1*subZOffset); if( DEBUG_SUB ) { System.err.println("MediaButton: NEXT pts "+pts+", "+sub); } } subBlend.draw(gl, renderer); final PMVMatrix4f pmv = renderer.getMatrix(); pmv.pushMv(); subLabel.applyMatToMv(pmv); subLabel.draw(gl, renderer); pmv.popMv(); } else if( SubtitleEvent.Type.Bitmap == sub.type ) { final SubBitmapEvent texSub = (SubBitmapEvent)sub; if( newSub ) { if( DEBUG_SUB ) { System.err.println("MediaButton: NEXT pts "+pts+", "+sub); } if( null != texSub.texture ) { final ImageSequence imgSeq = subTexImg.getImageSequence(); imgSeq.removeAllFrames(); imgSeq.addFrame(gl, texSub.texture); final Vec2f v_sz = new Vec2f(mPlayer.getWidth(), mPlayer.getHeight()); final Vec2f v_sxy = new Vec2f( box.getWidth(), box.getHeight() ).div( v_sz ); final float v_s = Math.min(v_sxy.x(), v_sxy.y()); final Vec2f s_sz_s = new Vec2f(texSub.dimension).scale(v_s); subTexImg.setSize(s_sz_s.x(), s_sz_s.y()); final Vec2f v_ctr; if( CodecID.HDMV_PGS == sub.codec && mPlayer.getWidth() < 1920 && mPlayer.getHeight() == 1080 ) { // PGS subtitles < 1920 width, e.g. 4:3 1440 width but 1080p // usually are positioned to 1920 width screen. FIXME: Elaborate, find metrics v_ctr = new Vec2f(new Vec2f(1920, 1080)).scale(0.5f); // 1080p center } else { v_ctr = new Vec2f(v_sz).scale(0.5f); // original video size center } final Vec2f b_ctr = new Vec2f(box.getCenter()).scale(1/v_s); final Vec2f s_p0 = new Vec2f(texSub.position.x(), v_sz.y() - texSub.position.y() - texSub.dimension.y() ); // y-flip + texSub.position is top-left final Vec2f s_p0_s = s_p0.minus( v_ctr ).add( b_ctr ).scale( v_s ); subTexImg.moveTo(s_p0_s.x(), s_p0_s.y(), 2*subZOffset); if( DEBUG_SUB_LAYOUT ) { // Keep this to ease later adjustments due to specifications like PGS final Vec2f b_sz = new Vec2f(box.getWidth(), box.getHeight()); final float v_ar = v_sz.x()/v_sz.y(); final float b_ar = b_sz.x()/b_sz.y(); final float s_ar = s_sz_s.x()/s_sz_s.y(); final float s_x_centered = ( b_sz.x() - s_sz_s.x() ) * 0.5f; final Vec2f v_ctr_1080p = new Vec2f(new Vec2f(1920, 1080)).scale(0.5f); // 1080p center final Vec2f v_ctr_o = new Vec2f(v_sz).scale(0.5f); // original video size center final Vec2f s_sz = new Vec2f(texSub.dimension); final Vec2f b_ctr_s = new Vec2f(box.getCenter()); final Vec2f v_p0_ctr = s_p0.minus(v_ctr); // p0 -> v_sz center final Vec2f s_p1 = b_ctr.plus(v_p0_ctr); System.err.println("XX video "+v_sz+" (ar "+v_ar+"), ( v_ctr "+v_ctr_o+", v_ctr_1080p "+v_ctr_1080p+" ) -> v_ctr "+v_ctr); System.err.println("XX sub s_sz "+s_sz+", s_sz_s "+s_sz_s+" (ar "+s_ar+")"); System.err.println("XX box "+b_sz+" (ar "+b_ar+"), b_ctr "+b_ctr+", b_ctr_s "+b_ctr_s); System.err.println("XXX v_s "+v_sxy+" -> "+v_s+": sz "+s_sz_s); System.err.println("XXX p0 "+s_p0+", v_p0_ctr "+v_p0_ctr+", s_p1 "+s_p1+" -> s_p1_s "+s_p0_s+"; sxs_2 "+s_x_centered); } } } final PMVMatrix4f pmv = renderer.getMatrix(); pmv.pushMv(); subTexImg.applyMatToMv(pmv); subTexImg.draw(gl, renderer); pmv.popMv(); } } }