WebKit Bugzilla
New
Browse
Search+
Log In
×
Sign in with GitHub
or
Remember my login
Create Account
·
Forgot Password
Forgotten password account recovery
[patch]
Patch
bug-162900-20161021212305.patch (text/plain), 40.17 KB, created by
Enrique Ocaña
on 2016-10-21 14:26:45 PDT
(
hide
)
Description:
Patch
Filename:
MIME Type:
Creator:
Enrique Ocaña
Created:
2016-10-21 14:26:45 PDT
Size:
40.17 KB
patch
obsolete
>Subversion Revision: 207639 >diff --git a/Source/WebCore/ChangeLog b/Source/WebCore/ChangeLog >index 75fe0a0ab3ed095ead8006413803a18ce04f583e..e8b88bb343b550bddfde5b6666e1b205f6c9bd56 100644 >--- a/Source/WebCore/ChangeLog >+++ b/Source/WebCore/ChangeLog >@@ -1,5 +1,62 @@ > 2016-10-04 Enrique Ocaña González <eocanha@igalia.com> > >+ [GStreamer][MSE][EME] MSE specialization of Media Player Private for GStreamer >+ https://bugs.webkit.org/show_bug.cgi?id=162900 >+ >+ Reviewed by Xabier Rodriguez-Calvar. >+ >+ Refactor MSE-specific logic to its own GStreamer player. This new MSE player coordinates >+ data appending, media playback and interaction with MediaSource by delegating to >+ AppendPipeline (one instance per SourceBuffer), PlaybackPipeline and >+ MediaSourceClientGStreamerMSE/MediaSourcePrivateClient respectively. >+ >+ This patch is co-authored with Philippe Normand <philn@igalia.com> (EME support). >+ >+ * platform/graphics/MediaPlayer.cpp: >+ (WebCore::buildMediaEnginesVector): >+ * platform/graphics/gstreamer/mse/MediaPlayerPrivateGStreamerMSE.cpp: Added. >+ (dumpReadyState): >+ (WebCore::MediaPlayerPrivateGStreamerMSE::registerMediaEngine): >+ (WebCore::initializeGStreamerAndRegisterWebKitMSEElement): >+ (WebCore::MediaPlayerPrivateGStreamerMSE::isAvailable): >+ (WebCore::MediaPlayerPrivateGStreamerMSE::MediaPlayerPrivateGStreamerMSE): >+ (WebCore::MediaPlayerPrivateGStreamerMSE::~MediaPlayerPrivateGStreamerMSE): >+ (WebCore::MediaPlayerPrivateGStreamerMSE::load): >+ (WebCore::MediaPlayerPrivateGStreamerMSE::pause): >+ (WebCore::MediaPlayerPrivateGStreamerMSE::durationMediaTime): >+ (WebCore::MediaPlayerPrivateGStreamerMSE::seek): >+ (WebCore::MediaPlayerPrivateGStreamerMSE::configurePlaySink): >+ (WebCore::MediaPlayerPrivateGStreamerMSE::changePipelineState): >+ (WebCore::MediaPlayerPrivateGStreamerMSE::notifySeekNeedsDataForTime): >+ (WebCore::MediaPlayerPrivateGStreamerMSE::doSeek): >+ (WebCore::MediaPlayerPrivateGStreamerMSE::maybeFinishSeek): >+ (WebCore::MediaPlayerPrivateGStreamerMSE::updatePlaybackRate): >+ (WebCore::MediaPlayerPrivateGStreamerMSE::seeking): >+ (WebCore::MediaPlayerPrivateGStreamerMSE::setReadyState): >+ (WebCore::MediaPlayerPrivateGStreamerMSE::waitForSeekCompleted): >+ (WebCore::MediaPlayerPrivateGStreamerMSE::seekCompleted): >+ (WebCore::MediaPlayerPrivateGStreamerMSE::setRate): >+ (WebCore::MediaPlayerPrivateGStreamerMSE::buffered): >+ (WebCore::MediaPlayerPrivateGStreamerMSE::sourceChanged): >+ (WebCore::MediaPlayerPrivateGStreamerMSE::updateStates): >+ (WebCore::MediaPlayerPrivateGStreamerMSE::asyncStateChangeDone): >+ (WebCore::MediaPlayerPrivateGStreamerMSE::isTimeBuffered): >+ (WebCore::MediaPlayerPrivateGStreamerMSE::setMediaSourceClient): >+ (WebCore::MediaPlayerPrivateGStreamerMSE::mediaSourceClient): >+ (WebCore::MediaPlayerPrivateGStreamerMSE::durationChanged): >+ (WebCore::mimeTypeCache): >+ (WebCore::MediaPlayerPrivateGStreamerMSE::getSupportedTypes): >+ (WebCore::MediaPlayerPrivateGStreamerMSE::trackDetected): >+ (WebCore::MediaPlayerPrivateGStreamerMSE::supportsType): >+ (WebCore::MediaPlayerPrivateGStreamerMSE::dispatchDecryptionKey): >+ (WebCore::MediaPlayerPrivateGStreamerMSE::markEndOfStream): >+ (WebCore::MediaPlayerPrivateGStreamerMSE::currentMediaTime): >+ (WebCore::MediaPlayerPrivateGStreamerMSE::maxTimeSeekable): >+ * platform/graphics/gstreamer/mse/MediaPlayerPrivateGStreamerMSE.h: Added. >+ (WebCore::MediaPlayerPrivateGStreamerMSE::mediaSourcePrivateClient): >+ >+2016-10-04 Enrique Ocaña González <eocanha@igalia.com> >+ > [GStreamer][MSE] MediaSourceGStreamer refactoring > https://bugs.webkit.org/show_bug.cgi?id=162899 > >diff --git a/Source/WebCore/platform/graphics/MediaPlayer.cpp b/Source/WebCore/platform/graphics/MediaPlayer.cpp >index 15ba6f3a7e8c701e31781ca66cb6f84f4ae02ab9..e5d6c35d959660fe9e612a334c2830f76e6997dd 100644 >--- a/Source/WebCore/platform/graphics/MediaPlayer.cpp >+++ b/Source/WebCore/platform/graphics/MediaPlayer.cpp >@@ -57,7 +57,10 @@ > #include "MediaPlayerPrivateGStreamerOwr.h" > #endif > #define PlatformMediaEngineClassName MediaPlayerPrivateGStreamer >+#if ENABLE(VIDEO) && ENABLE(MEDIA_SOURCE) && ENABLE(VIDEO_TRACK) >+#include "MediaPlayerPrivateGStreamerMSE.h" > #endif >+#endif // USE(GSTREAMER) > > #if USE(MEDIA_FOUNDATION) > #include "MediaPlayerPrivateMediaFoundation.h" >@@ -229,6 +232,10 @@ static void buildMediaEnginesVector() > PlatformMediaEngineClassName::registerMediaEngine(addMediaEngine); > #endif > >+#if ENABLE(VIDEO) && USE(GSTREAMER) && ENABLE(MEDIA_SOURCE) && ENABLE(VIDEO_TRACK) >+ MediaPlayerPrivateGStreamerMSE::registerMediaEngine(addMediaEngine); >+#endif >+ > haveMediaEnginesVector() = true; > } > >diff --git a/Source/WebCore/platform/graphics/gstreamer/mse/MediaPlayerPrivateGStreamerMSE.cpp b/Source/WebCore/platform/graphics/gstreamer/mse/MediaPlayerPrivateGStreamerMSE.cpp >new file mode 100644 >index 0000000000000000000000000000000000000000..f270e06e6f8e5be88ec412d97cada2c4a266def9 >--- /dev/null >+++ b/Source/WebCore/platform/graphics/gstreamer/mse/MediaPlayerPrivateGStreamerMSE.cpp >@@ -0,0 +1,836 @@ >+/* >+ * Copyright (C) 2007, 2009 Apple Inc. All rights reserved. >+ * Copyright (C) 2007 Collabora Ltd. All rights reserved. >+ * Copyright (C) 2007 Alp Toker <alp@atoker.com> >+ * Copyright (C) 2009 Gustavo Noronha Silva <gns@gnome.org> >+ * Copyright (C) 2009, 2010, 2011, 2012, 2013, 2016 Igalia S.L >+ * Copyright (C) 2015 Sebastian Dröge <sebastian@centricular.com> >+ * Copyright (C) 2015, 2016 Metrological Group B.V. >+ * >+ * This library is free software; you can redistribute it and/or >+ * modify it under the terms of the GNU Library General Public >+ * License as published by the Free Software Foundation; either >+ * version 2 of the License, or (at your option) any later version. >+ * >+ * This library is distributed in the hope that it will be useful, >+ * but WITHOUT ANY WARRANTY; without even the implied warranty of >+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU >+ * Library General Public License for more details. >+ * >+ * You should have received a copy of the GNU Library General Public License >+ * aint with this library; see the file COPYING.LIB. If not, write to >+ * the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, >+ * Boston, MA 02110-1301, USA. >+ */ >+ >+#include "config.h" >+#include "MediaPlayerPrivateGStreamerMSE.h" >+ >+#if ENABLE(VIDEO) && USE(GSTREAMER) && ENABLE(MEDIA_SOURCE) >+ >+#include "AppendPipeline.h" >+#include "AudioTrackPrivateGStreamer.h" >+#include "GStreamerUtilities.h" >+#include "InbandTextTrackPrivateGStreamer.h" >+#include "MIMETypeRegistry.h" >+#include "MediaDescription.h" >+#include "MediaPlayer.h" >+#include "NotImplemented.h" >+#include "SourceBufferPrivateGStreamer.h" >+#include "TimeRanges.h" >+#include "URL.h" >+#include "VideoTrackPrivateGStreamer.h" >+ >+#include <gst/app/gstappsink.h> >+#include <gst/app/gstappsrc.h> >+#include <gst/gst.h> >+#include <gst/pbutils/pbutils.h> >+#include <gst/video/video.h> >+#include <wtf/Condition.h> >+#include <wtf/NeverDestroyed.h> >+ >+static const char* dumpReadyState(WebCore::MediaPlayer::ReadyState readyState) >+{ >+ switch (readyState) { >+ case WebCore::MediaPlayer::HaveNothing: return "HaveNothing"; >+ case WebCore::MediaPlayer::HaveMetadata: return "HaveMetadata"; >+ case WebCore::MediaPlayer::HaveCurrentData: return "HaveCurrentData"; >+ case WebCore::MediaPlayer::HaveFutureData: return "HaveFutureData"; >+ case WebCore::MediaPlayer::HaveEnoughData: return "HaveEnoughData"; >+ default: return "(unknown)"; >+ } >+} >+ >+// Max interval in seconds to stay in the READY state on manual state change requests. >+static const unsigned gReadyStateTimerInterval = 60; >+ >+GST_DEBUG_CATEGORY(webkit_mse_debug); >+#define GST_CAT_DEFAULT webkit_mse_debug >+ >+namespace WebCore { >+ >+void MediaPlayerPrivateGStreamerMSE::registerMediaEngine(MediaEngineRegistrar registrar) >+{ >+ if (isAvailable()) { >+ registrar([](MediaPlayer* player) { return std::make_unique<MediaPlayerPrivateGStreamerMSE>(player); }, >+ getSupportedTypes, supportsType, nullptr, nullptr, nullptr, supportsKeySystem); >+ } >+} >+ >+bool initializeGStreamerAndRegisterWebKitMSEElement() >+{ >+ if (UNLIKELY(!initializeGStreamer())) >+ return false; >+ >+ registerWebKitGStreamerElements(); >+ >+ GST_DEBUG_CATEGORY_INIT(webkit_mse_debug, "webkitmse", 0, "WebKit MSE media player"); >+ >+ GRefPtr<GstElementFactory> WebKitMediaSrcFactory = adoptGRef(gst_element_factory_find("webkitmediasrc")); >+ if (UNLIKELY(!WebKitMediaSrcFactory)) >+ gst_element_register(nullptr, "webkitmediasrc", GST_RANK_PRIMARY + 100, WEBKIT_TYPE_MEDIA_SRC); >+ return true; >+} >+ >+bool MediaPlayerPrivateGStreamerMSE::isAvailable() >+{ >+ if (UNLIKELY(!initializeGStreamerAndRegisterWebKitMSEElement())) >+ return false; >+ >+ GRefPtr<GstElementFactory> factory = adoptGRef(gst_element_factory_find("playbin")); >+ return factory; >+} >+ >+MediaPlayerPrivateGStreamerMSE::MediaPlayerPrivateGStreamerMSE(MediaPlayer* player) >+ : MediaPlayerPrivateGStreamer(player) >+{ >+ GST_TRACE("creating the player (%p)", this); >+} >+ >+MediaPlayerPrivateGStreamerMSE::~MediaPlayerPrivateGStreamerMSE() >+{ >+ GST_TRACE("destroying the player (%p)", this); >+ >+ for (auto iterator : m_appendPipelinesMap) >+ iterator.value->clearPlayerPrivate(); >+ >+ if (m_source) { >+ webKitMediaSrcSetMediaPlayerPrivate(WEBKIT_MEDIA_SRC(m_source.get()), nullptr); >+ g_signal_handlers_disconnect_by_data(m_source.get(), this); >+ } >+ >+ if (m_playbackPipeline) >+ m_playbackPipeline->setWebKitMediaSrc(nullptr); >+} >+ >+void MediaPlayerPrivateGStreamerMSE::load(const String& urlString) >+{ >+ if (UNLIKELY(!initializeGStreamerAndRegisterWebKitMSEElement())) >+ return; >+ >+ if (!urlString.startsWith("mediasource")) { >+ GST_ERROR("Unsupported url: %s", urlString.utf8().data()); >+ return; >+ } >+ >+ if (!m_playbackPipeline) >+ m_playbackPipeline = PlaybackPipeline::create(); >+ >+ MediaPlayerPrivateGStreamer::load(urlString); >+} >+ >+void MediaPlayerPrivateGStreamerMSE::load(const String& url, MediaSourcePrivateClient* mediaSource) >+{ >+ m_mediaSource = mediaSource; >+ load(String::format("mediasource%s", url.utf8().data())); >+} >+ >+void MediaPlayerPrivateGStreamerMSE::pause() >+{ >+ m_paused = true; >+ MediaPlayerPrivateGStreamer::pause(); >+} >+ >+MediaTime MediaPlayerPrivateGStreamerMSE::durationMediaTime() const >+{ >+ if (UNLIKELY(!m_pipeline || m_errorOccured)) >+ return MediaTime(); >+ >+ return m_mediaTimeDuration; >+} >+ >+void MediaPlayerPrivateGStreamerMSE::seek(float time) >+{ >+ if (UNLIKELY(!m_pipeline || m_errorOccured)) >+ return; >+ >+ GST_INFO("[Seek] seek attempt to %f secs", time); >+ >+ // Avoid useless seeking. >+ float current = currentMediaTime().toFloat(); >+ if (time == current) { >+ if (!m_seeking) >+ timeChanged(); >+ return; >+ } >+ >+ if (isLiveStream()) >+ return; >+ >+ if (m_seeking && m_seekIsPending) { >+ m_seekTime = time; >+ return; >+ } >+ >+ GST_DEBUG("Seeking from %f to %f seconds", current, time); >+ >+ float prevSeekTime = m_seekTime; >+ m_seekTime = time; >+ >+ if (!doSeek()) { >+ m_seekTime = prevSeekTime; >+ GST_WARNING("Seeking to %f failed", time); >+ return; >+ } >+ >+ m_isEndReached = false; >+ GST_DEBUG("m_seeking=%s, m_seekTime=%f", m_seeking ? "true" : "false", m_seekTime); >+} >+ >+void MediaPlayerPrivateGStreamerMSE::configurePlaySink() >+{ >+ MediaPlayerPrivateGStreamer::configurePlaySink(); >+ >+ GRefPtr<GstElement> playsink = adoptGRef(gst_bin_get_by_name(GST_BIN(m_pipeline.get()), "playsink")); >+ if (playsink) { >+ // The default value (0) means "send events to all the sinks", instead >+ // of "only to the first that returns true". This is needed for MSE seek. >+ g_object_set(G_OBJECT(playsink.get()), "send-event-mode", 0, nullptr); >+ } >+} >+ >+bool MediaPlayerPrivateGStreamerMSE::changePipelineState(GstState newState) >+{ >+ if (seeking()) { >+ GST_DEBUG("Rejected state change to %s while seeking", >+ gst_element_state_get_name(newState)); >+ return true; >+ } >+ >+ return MediaPlayerPrivateGStreamer::changePipelineState(newState); >+} >+ >+void MediaPlayerPrivateGStreamerMSE::notifySeekNeedsDataForTime(const MediaTime& seekTime) >+{ >+ // Reenqueue samples needed to resume playback in the new position. >+ m_mediaSource->seekToTime(seekTime); >+ >+ GST_DEBUG("MSE seek to %f finished", seekTime.toDouble()); >+ >+ if (!m_gstSeekCompleted) { >+ m_gstSeekCompleted = true; >+ maybeFinishSeek(); >+ } >+} >+ >+bool MediaPlayerPrivateGStreamerMSE::doSeek(gint64, float, GstSeekFlags) >+{ >+ // Use doSeek() instead. If anybody is calling this version of doSeek(), something is wrong. >+ ASSERT_NOT_REACHED(); >+ return false; >+} >+ >+bool MediaPlayerPrivateGStreamerMSE::doSeek() >+{ >+ GstClockTime position = toGstClockTime(m_seekTime); >+ MediaTime seekTime = MediaTime::createWithDouble(m_seekTime); >+ double rate = m_player->rate(); >+ GstSeekFlags seekType = static_cast<GstSeekFlags>(GST_SEEK_FLAG_FLUSH | GST_SEEK_FLAG_ACCURATE); >+ >+ // Always move to seeking state to report correct 'currentTime' while pending for actual seek to complete. >+ m_seeking = true; >+ >+ // Check if playback pipeline is ready for seek. >+ GstState state, newState; >+ GstStateChangeReturn getStateResult = gst_element_get_state(m_pipeline.get(), &state, &newState, 0); >+ if (getStateResult == GST_STATE_CHANGE_FAILURE || getStateResult == GST_STATE_CHANGE_NO_PREROLL) { >+ GST_DEBUG("[Seek] cannot seek, current state change is %s", gst_element_state_change_return_get_name(getStateResult)); >+ webKitMediaSrcSetReadyForSamples(WEBKIT_MEDIA_SRC(m_source.get()), true); >+ m_seeking = false; >+ return false; >+ } >+ if ((getStateResult == GST_STATE_CHANGE_ASYNC >+ && !(state == GST_STATE_PLAYING && newState == GST_STATE_PAUSED)) >+ || state < GST_STATE_PAUSED >+ || m_isEndReached >+ || !m_gstSeekCompleted) { >+ CString reason = "Unknown reason"; >+ if (getStateResult == GST_STATE_CHANGE_ASYNC) { >+ reason = String::format("In async change %s --> %s", >+ gst_element_state_get_name(state), >+ gst_element_state_get_name(newState)).utf8(); >+ } else if (state < GST_STATE_PAUSED) >+ reason = "State less than PAUSED"; >+ else if (m_isEndReached) >+ reason = "End reached"; >+ else if (!m_gstSeekCompleted) >+ reason = "Previous seek is not finished yet"; >+ >+ GST_DEBUG("[Seek] Delaying the seek: %s", reason.data()); >+ >+ m_seekIsPending = true; >+ >+ if (m_isEndReached) { >+ GST_DEBUG("[Seek] reset pipeline"); >+ m_resetPipeline = true; >+ m_seeking = false; >+ if (!changePipelineState(GST_STATE_PAUSED)) >+ loadingFailed(MediaPlayer::Empty); >+ else >+ m_seeking = true; >+ } >+ >+ return m_seeking; >+ } >+ >+ // Stop accepting new samples until actual seek is finished. >+ webKitMediaSrcSetReadyForSamples(WEBKIT_MEDIA_SRC(m_source.get()), false); >+ >+ // Correct seek time if it helps to fix a small gap. >+ if (!isTimeBuffered(seekTime)) { >+ // Look if a near future time (<0.1 sec.) is buffered and change the seek target time. >+ if (m_mediaSource) { >+ const MediaTime miniGap = MediaTime::createWithDouble(0.1); >+ MediaTime nearest = m_mediaSource->buffered()->nearest(seekTime); >+ if (nearest.isValid() && nearest > seekTime && (nearest - seekTime) <= miniGap && isTimeBuffered(nearest + miniGap)) { >+ GST_DEBUG("[Seek] Changed the seek target time from %f to %f, a near point in the future", seekTime.toFloat(), nearest.toFloat()); >+ seekTime = nearest; >+ } >+ } >+ } >+ >+ // Check if MSE has samples for requested time and defer actual seek if needed. >+ if (!isTimeBuffered(seekTime)) { >+ GST_DEBUG("[Seek] Delaying the seek: MSE is not ready"); >+ GstStateChangeReturn setStateResult = gst_element_set_state(m_pipeline.get(), GST_STATE_PAUSED); >+ if (setStateResult == GST_STATE_CHANGE_FAILURE) { >+ GST_DEBUG("[Seek] Cannot seek, failed to pause playback pipeline."); >+ webKitMediaSrcSetReadyForSamples(WEBKIT_MEDIA_SRC(m_source.get()), true); >+ m_seeking = false; >+ return false; >+ } >+ m_readyState = MediaPlayer::HaveMetadata; >+ notifySeekNeedsDataForTime(seekTime); >+ ASSERT(!m_mseSeekCompleted); >+ return true; >+ } >+ >+ // Complete previous MSE seek if needed. >+ if (!m_mseSeekCompleted) { >+ m_mediaSource->monitorSourceBuffers(); >+ ASSERT(m_mseSeekCompleted); >+ // Note: seekCompleted will recursively call us. >+ return m_seeking; >+ } >+ >+ GST_DEBUG("We can seek now"); >+ >+ gint64 startTime = position, endTime = GST_CLOCK_TIME_NONE; >+ if (rate < 0) { >+ startTime = 0; >+ endTime = position; >+ } >+ >+ if (!rate) >+ rate = 1; >+ >+ GST_DEBUG("Actual seek to %" GST_TIME_FORMAT ", end time: %" GST_TIME_FORMAT ", rate: %f", GST_TIME_ARGS(startTime), GST_TIME_ARGS(endTime), rate); >+ >+ // This will call notifySeekNeedsData() after some time to tell that the pipeline is ready for sample enqueuing. >+ webKitMediaSrcPrepareSeek(WEBKIT_MEDIA_SRC(m_source.get()), seekTime); >+ >+ m_gstSeekCompleted = false; >+ if (!gst_element_seek(m_pipeline.get(), rate, GST_FORMAT_TIME, seekType, GST_SEEK_TYPE_SET, startTime, GST_SEEK_TYPE_SET, endTime)) { >+ webKitMediaSrcSetReadyForSamples(WEBKIT_MEDIA_SRC(m_source.get()), true); >+ m_seeking = false; >+ m_gstSeekCompleted = true; >+ GST_DEBUG("doSeek(): gst_element_seek() failed, returning false"); >+ return false; >+ } >+ >+ // The samples will be enqueued in notifySeekNeedsData(). >+ GST_DEBUG("doSeek(): gst_element_seek() succeeded, returning true"); >+ return true; >+} >+ >+void MediaPlayerPrivateGStreamerMSE::maybeFinishSeek() >+{ >+ if (!m_seeking || !m_mseSeekCompleted || !m_gstSeekCompleted) >+ return; >+ >+ GstState state, newState; >+ GstStateChangeReturn getStateResult = gst_element_get_state(m_pipeline.get(), &state, &newState, 0); >+ >+ if (getStateResult == GST_STATE_CHANGE_ASYNC >+ && !(state == GST_STATE_PLAYING && newState == GST_STATE_PAUSED)) { >+ GST_DEBUG("[Seek] Delaying seek finish"); >+ return; >+ } >+ >+ if (m_seekIsPending) { >+ GST_DEBUG("[Seek] Committing pending seek to %f", m_seekTime); >+ m_seekIsPending = false; >+ if (!doSeek()) { >+ GST_WARNING("[Seek] Seeking to %f failed", m_seekTime); >+ m_cachedPosition = -1; >+ } >+ return; >+ } >+ >+ GST_DEBUG("[Seek] Seeked to %f", m_seekTime); >+ >+ webKitMediaSrcSetReadyForSamples(WEBKIT_MEDIA_SRC(m_source.get()), true); >+ m_seeking = false; >+ m_cachedPosition = -1; >+ // The pipeline can still have a pending state. In this case a position query will fail. >+ // Right now we can use m_seekTime as a fallback. >+ m_canFallBackToLastFinishedSeekPosition = true; >+ timeChanged(); >+} >+ >+void MediaPlayerPrivateGStreamerMSE::updatePlaybackRate() >+{ >+ notImplemented(); >+} >+ >+bool MediaPlayerPrivateGStreamerMSE::seeking() const >+{ >+ return m_seeking; >+} >+ >+// FIXME: MediaPlayerPrivateGStreamer manages the ReadyState on its own. We shouldn't change it manually. >+void MediaPlayerPrivateGStreamerMSE::setReadyState(MediaPlayer::ReadyState readyState) >+{ >+ if (readyState == m_readyState) >+ return; >+ >+ if (seeking()) { >+ GST_DEBUG("Skip ready state change(%s -> %s) due to seek\n", dumpReadyState(m_readyState), dumpReadyState(readyState)); >+ return; >+ } >+ >+ GST_DEBUG("Ready State Changed manually from %u to %u", m_readyState, readyState); >+ MediaPlayer::ReadyState oldReadyState = m_readyState; >+ m_readyState = readyState; >+ GST_DEBUG("m_readyState: %s -> %s", dumpReadyState(oldReadyState), dumpReadyState(m_readyState)); >+ >+ if (oldReadyState < MediaPlayer::HaveCurrentData && m_readyState >= MediaPlayer::HaveCurrentData) { >+ GST_DEBUG("[Seek] Reporting load state changed to trigger seek continuation"); >+ loadStateChanged(); >+ } >+ m_player->readyStateChanged(); >+ >+ GstState pipelineState; >+ GstStateChangeReturn getStateResult = gst_element_get_state(m_pipeline.get(), &pipelineState, nullptr, 250 * GST_NSECOND); >+ bool isPlaying = (getStateResult == GST_STATE_CHANGE_SUCCESS && pipelineState == GST_STATE_PLAYING); >+ >+ if (m_readyState == MediaPlayer::HaveMetadata && oldReadyState > MediaPlayer::HaveMetadata && isPlaying) { >+ GST_TRACE("Changing pipeline to PAUSED..."); >+ bool ok = changePipelineState(GST_STATE_PAUSED); >+ GST_TRACE("Changed pipeline to PAUSED: %s", ok ? "Success" : "Error"); >+ } >+} >+ >+void MediaPlayerPrivateGStreamerMSE::waitForSeekCompleted() >+{ >+ if (!m_seeking) >+ return; >+ >+ GST_DEBUG("Waiting for MSE seek completed"); >+ m_mseSeekCompleted = false; >+} >+ >+void MediaPlayerPrivateGStreamerMSE::seekCompleted() >+{ >+ if (m_mseSeekCompleted) >+ return; >+ >+ GST_DEBUG("MSE seek completed"); >+ m_mseSeekCompleted = true; >+ >+ doSeek(); >+ >+ if (!seeking() && m_readyState >= MediaPlayer::HaveFutureData) >+ changePipelineState(GST_STATE_PLAYING); >+ >+ if (!seeking()) >+ m_player->timeChanged(); >+} >+ >+void MediaPlayerPrivateGStreamerMSE::setRate(float) >+{ >+ notImplemented(); >+} >+ >+std::unique_ptr<PlatformTimeRanges> MediaPlayerPrivateGStreamerMSE::buffered() const >+{ >+ return m_mediaSource->buffered(); >+} >+ >+void MediaPlayerPrivateGStreamerMSE::sourceChanged() >+{ >+ m_source = nullptr; >+ g_object_get(m_pipeline.get(), "source", &m_source.outPtr(), nullptr); >+ >+ ASSERT(WEBKIT_IS_MEDIA_SRC(m_source.get())); >+ >+ m_playbackPipeline->setWebKitMediaSrc(WEBKIT_MEDIA_SRC(m_source.get())); >+ >+ MediaSourceGStreamer::open(*m_mediaSource.get(), *this); >+ g_signal_connect_swapped(m_source.get(), "video-changed", G_CALLBACK(videoChangedCallback), this); >+ g_signal_connect_swapped(m_source.get(), "audio-changed", G_CALLBACK(audioChangedCallback), this); >+ g_signal_connect_swapped(m_source.get(), "text-changed", G_CALLBACK(textChangedCallback), this); >+ webKitMediaSrcSetMediaPlayerPrivate(WEBKIT_MEDIA_SRC(m_source.get()), this); >+} >+ >+void MediaPlayerPrivateGStreamerMSE::updateStates() >+{ >+ if (UNLIKELY(!m_pipeline || m_errorOccured)) >+ return; >+ >+ MediaPlayer::NetworkState oldNetworkState = m_networkState; >+ MediaPlayer::ReadyState oldReadyState = m_readyState; >+ GstState state, pending; >+ >+ GstStateChangeReturn getStateResult = gst_element_get_state(m_pipeline.get(), &state, &pending, 250 * GST_NSECOND); >+ >+ bool shouldUpdatePlaybackState = false; >+ switch (getStateResult) { >+ case GST_STATE_CHANGE_SUCCESS: { >+ GST_DEBUG("State: %s, pending: %s", gst_element_state_get_name(state), gst_element_state_get_name(pending)); >+ >+ // Do nothing if on EOS and state changed to READY to avoid recreating the player >+ // on HTMLMediaElement and properly generate the video 'ended' event. >+ if (m_isEndReached && state == GST_STATE_READY) >+ break; >+ >+ m_resetPipeline = (state <= GST_STATE_READY); >+ if (m_resetPipeline) >+ m_mediaTimeDuration = MediaTime::zeroTime(); >+ >+ // Update ready and network states. >+ switch (state) { >+ case GST_STATE_NULL: >+ m_readyState = MediaPlayer::HaveNothing; >+ GST_DEBUG("m_readyState=%s", dumpReadyState(m_readyState)); >+ m_networkState = MediaPlayer::Empty; >+ break; >+ case GST_STATE_READY: >+ m_readyState = MediaPlayer::HaveMetadata; >+ GST_DEBUG("m_readyState=%s", dumpReadyState(m_readyState)); >+ m_networkState = MediaPlayer::Empty; >+ break; >+ case GST_STATE_PAUSED: >+ case GST_STATE_PLAYING: >+ if (seeking()) { >+ m_readyState = MediaPlayer::HaveMetadata; >+ // FIXME: Should we manage NetworkState too? >+ GST_DEBUG("m_readyState=%s", dumpReadyState(m_readyState)); >+ } else if (m_buffering) { >+ if (m_bufferingPercentage == 100) { >+ GST_DEBUG("[Buffering] Complete."); >+ m_buffering = false; >+ m_readyState = MediaPlayer::HaveEnoughData; >+ GST_DEBUG("m_readyState=%s", dumpReadyState(m_readyState)); >+ m_networkState = m_downloadFinished ? MediaPlayer::Idle : MediaPlayer::Loading; >+ } else { >+ m_readyState = MediaPlayer::HaveCurrentData; >+ GST_DEBUG("m_readyState=%s", dumpReadyState(m_readyState)); >+ m_networkState = MediaPlayer::Loading; >+ } >+ } else if (m_downloadFinished) { >+ m_readyState = MediaPlayer::HaveEnoughData; >+ GST_DEBUG("m_readyState=%s", dumpReadyState(m_readyState)); >+ m_networkState = MediaPlayer::Loaded; >+ } else { >+ m_readyState = MediaPlayer::HaveFutureData; >+ GST_DEBUG("m_readyState=%s", dumpReadyState(m_readyState)); >+ m_networkState = MediaPlayer::Loading; >+ } >+ >+ if (m_eosMarked && state == GST_STATE_PLAYING) >+ m_eosPending = true; >+ >+ break; >+ default: >+ ASSERT_NOT_REACHED(); >+ break; >+ } >+ >+ // Sync states where needed. >+ if (state == GST_STATE_PAUSED) { >+ if (!m_volumeAndMuteInitialized) { >+ notifyPlayerOfVolumeChange(); >+ notifyPlayerOfMute(); >+ m_volumeAndMuteInitialized = true; >+ } >+ >+ if (!seeking() && !m_buffering && !m_paused && m_playbackRate) { >+ GST_DEBUG("[Buffering] Restarting playback."); >+ changePipelineState(GST_STATE_PLAYING); >+ } >+ } else if (state == GST_STATE_PLAYING) { >+ m_paused = false; >+ >+ if ((m_buffering && !isLiveStream()) || !m_playbackRate) { >+ GST_DEBUG("[Buffering] Pausing stream for buffering."); >+ changePipelineState(GST_STATE_PAUSED); >+ } >+ } else >+ m_paused = true; >+ >+ if (m_requestedState == GST_STATE_PAUSED && state == GST_STATE_PAUSED) { >+ shouldUpdatePlaybackState = true; >+ GST_DEBUG("Requested state change to %s was completed", gst_element_state_get_name(state)); >+ } >+ >+ break; >+ } >+ case GST_STATE_CHANGE_ASYNC: >+ GST_DEBUG("Async: State: %s, pending: %s", gst_element_state_get_name(state), gst_element_state_get_name(pending)); >+ // Change in progress. >+ break; >+ case GST_STATE_CHANGE_FAILURE: >+ GST_WARNING("Failure: State: %s, pending: %s", gst_element_state_get_name(state), gst_element_state_get_name(pending)); >+ // Change failed. >+ return; >+ case GST_STATE_CHANGE_NO_PREROLL: >+ GST_DEBUG("No preroll: State: %s, pending: %s", gst_element_state_get_name(state), gst_element_state_get_name(pending)); >+ >+ // Live pipelines go in PAUSED without prerolling. >+ m_isStreaming = true; >+ >+ if (state == GST_STATE_READY) { >+ m_readyState = MediaPlayer::HaveNothing; >+ GST_DEBUG("m_readyState=%s", dumpReadyState(m_readyState)); >+ } else if (state == GST_STATE_PAUSED) { >+ m_readyState = MediaPlayer::HaveEnoughData; >+ GST_DEBUG("m_readyState=%s", dumpReadyState(m_readyState)); >+ m_paused = true; >+ } else if (state == GST_STATE_PLAYING) >+ m_paused = false; >+ >+ if (!m_paused && m_playbackRate) >+ changePipelineState(GST_STATE_PLAYING); >+ >+ m_networkState = MediaPlayer::Loading; >+ break; >+ default: >+ GST_DEBUG("Else : %d", getStateResult); >+ break; >+ } >+ >+ m_requestedState = GST_STATE_VOID_PENDING; >+ >+ if (shouldUpdatePlaybackState) >+ m_player->playbackStateChanged(); >+ >+ if (m_networkState != oldNetworkState) { >+ GST_DEBUG("Network State Changed from %u to %u", oldNetworkState, m_networkState); >+ m_player->networkStateChanged(); >+ } >+ if (m_readyState != oldReadyState) { >+ GST_DEBUG("Ready State Changed from %u to %u", oldReadyState, m_readyState); >+ m_player->readyStateChanged(); >+ } >+ >+ if (getStateResult == GST_STATE_CHANGE_SUCCESS && state >= GST_STATE_PAUSED) { >+ updatePlaybackRate(); >+ maybeFinishSeek(); >+ } >+} >+void MediaPlayerPrivateGStreamerMSE::asyncStateChangeDone() >+{ >+ if (UNLIKELY(!m_pipeline || m_errorOccured)) >+ return; >+ >+ if (m_seeking) >+ maybeFinishSeek(); >+ else >+ updateStates(); >+} >+ >+bool MediaPlayerPrivateGStreamerMSE::isTimeBuffered(const MediaTime &time) const >+{ >+ bool result = m_mediaSource && m_mediaSource->buffered()->contain(time); >+ GST_DEBUG("Time %f buffered? %s", time.toDouble(), result ? "Yes" : "No"); >+ return result; >+} >+ >+void MediaPlayerPrivateGStreamerMSE::setMediaSourceClient(Ref<MediaSourceClientGStreamerMSE> client) >+{ >+ m_mediaSourceClient = client.ptr(); >+} >+ >+RefPtr<MediaSourceClientGStreamerMSE> MediaPlayerPrivateGStreamerMSE::mediaSourceClient() >+{ >+ return m_mediaSourceClient; >+} >+ >+void MediaPlayerPrivateGStreamerMSE::durationChanged() >+{ >+ if (!m_mediaSourceClient) { >+ GST_DEBUG("m_mediaSourceClient is null, doing nothing"); >+ return; >+ } >+ >+ MediaTime previousDuration = m_mediaTimeDuration; >+ m_mediaTimeDuration = m_mediaSourceClient->duration(); >+ >+ GST_TRACE("previous=%f, new=%f", previousDuration.toFloat(), m_mediaTimeDuration.toFloat()); >+ >+ // Avoid emiting durationchanged in the case where the previous duration was 0 because that case is already handled >+ // by the HTMLMediaElement. >+ if (m_mediaTimeDuration != previousDuration && m_mediaTimeDuration.isValid() && previousDuration.isValid()) { >+ m_player->durationChanged(); >+ m_playbackPipeline->notifyDurationChanged(); >+ m_mediaSource->durationChanged(m_mediaTimeDuration); >+ } >+} >+ >+static HashSet<String, ASCIICaseInsensitiveHash>& mimeTypeCache() >+{ >+ static NeverDestroyed<HashSet<String, ASCIICaseInsensitiveHash>> cache = []() >+ { >+ initializeGStreamerAndRegisterWebKitMSEElement(); >+ HashSet<String, ASCIICaseInsensitiveHash> set; >+ const char* mimeTypes[] = { >+ "video/mp4", >+ "audio/mp4" >+ }; >+ for (auto& type : mimeTypes) >+ set.add(type); >+ return set; >+ }(); >+ return cache; >+} >+ >+void MediaPlayerPrivateGStreamerMSE::getSupportedTypes(HashSet<String, ASCIICaseInsensitiveHash>& types) >+{ >+ types = mimeTypeCache(); >+} >+ >+void MediaPlayerPrivateGStreamerMSE::trackDetected(RefPtr<AppendPipeline> appendPipeline, RefPtr<WebCore::TrackPrivateBase> oldTrack, RefPtr<WebCore::TrackPrivateBase> newTrack) >+{ >+ ASSERT(appendPipeline->track() == newTrack); >+ >+ GstCaps* caps = appendPipeline->appsinkCaps(); >+ ASSERT(caps); >+ GST_DEBUG("track ID: %s, caps: %" GST_PTR_FORMAT, newTrack->id().string().latin1().data(), caps); >+ >+ GstStructure* structure = gst_caps_get_structure(caps, 0); >+ const gchar* mediaType = gst_structure_get_name(structure); >+ GstVideoInfo info; >+ >+ if (g_str_has_prefix(mediaType, "video/") && gst_video_info_from_caps(&info, caps)) { >+ float width, height; >+ >+ width = info.width; >+ height = info.height * ((float) info.par_d / (float) info.par_n); >+ m_videoSize.setWidth(width); >+ m_videoSize.setHeight(height); >+ } >+ >+ if (!oldTrack) >+ m_playbackPipeline->attachTrack(appendPipeline->sourceBufferPrivate(), newTrack, structure, caps); >+ else >+ m_playbackPipeline->reattachTrack(appendPipeline->sourceBufferPrivate(), newTrack); >+} >+ >+MediaPlayer::SupportsType MediaPlayerPrivateGStreamerMSE::supportsType(const MediaEngineSupportParameters& parameters) >+{ >+ MediaPlayer::SupportsType result = MediaPlayer::IsNotSupported; >+ if (!parameters.isMediaSource) >+ return result; >+ >+ // Disable VPX/Opus on MSE for now, mp4/avc1 seems way more reliable currently. >+ if (parameters.type.endsWith("webm")) >+ return result; >+ >+ // YouTube TV provides empty types for some videos and we want to be selected as best media engine for them. >+ if (parameters.type.isEmpty()) { >+ result = MediaPlayer::MayBeSupported; >+ return result; >+ } >+ >+ // Spec says we should not return "probably" if the codecs string is empty. >+ if (mimeTypeCache().contains(parameters.type)) >+ result = parameters.codecs.isEmpty() ? MediaPlayer::MayBeSupported : MediaPlayer::IsSupported; >+ >+ return extendedSupportsType(parameters, result); >+} >+ >+#if ENABLE(LEGACY_ENCRYPTED_MEDIA) >+void MediaPlayerPrivateGStreamerMSE::dispatchDecryptionKey(GstBuffer* buffer) >+{ >+ for (auto iterator : m_appendPipelinesMap) { >+ if (iterator.value->appendState() == AppendPipeline::AppendState::KeyNegotiation) { >+ GST_TRACE("append pipeline %p in key negotiation, setting key", iterator.value.get()); >+ gst_element_send_event(iterator.value->pipeline(), gst_event_new_custom(GST_EVENT_CUSTOM_DOWNSTREAM_OOB, >+ gst_structure_new("drm-cipher", "key", GST_TYPE_BUFFER, buffer, nullptr))); >+ iterator.value->setAppendState(AppendPipeline::AppendState::Ongoing); >+ } else >+ GST_TRACE("append pipeline %p not in key negotiation", iterator.value.get()); >+ } >+} >+#endif >+ >+void MediaPlayerPrivateGStreamerMSE::markEndOfStream(MediaSourcePrivate::EndOfStreamStatus status) >+{ >+ if (status != MediaSourcePrivate::EosNoError) >+ return; >+ >+ GST_DEBUG("Marking end of stream"); >+ m_eosMarked = true; >+ updateStates(); >+} >+ >+MediaTime MediaPlayerPrivateGStreamerMSE::currentMediaTime() const >+{ >+ MediaTime position = MediaPlayerPrivateGStreamer::currentMediaTime(); >+ >+ if (m_eosPending && (paused() || (position >= durationMediaTime()))) { >+ if (m_networkState != MediaPlayer::Loaded) { >+ m_networkState = MediaPlayer::Loaded; >+ m_player->networkStateChanged(); >+ } >+ >+ m_eosPending = false; >+ m_isEndReached = true; >+ m_cachedPosition = m_mediaTimeDuration.toFloat(); >+ m_durationAtEOS = m_mediaTimeDuration.toFloat(); >+ m_player->timeChanged(); >+ } >+ return position; >+} >+ >+float MediaPlayerPrivateGStreamerMSE::maxTimeSeekable() const >+{ >+ if (UNLIKELY(m_errorOccured)) >+ return 0; >+ >+ GST_DEBUG("maxTimeSeekable"); >+ float result = durationMediaTime().toFloat(); >+ // Infinite duration means live stream. >+ if (isinf(result)) { >+ MediaTime maxBufferedTime = buffered()->maximumBufferedTime(); >+ // Return the highest end time reported by the buffered attribute. >+ result = maxBufferedTime.isValid() ? maxBufferedTime.toFloat() : 0; >+ } >+ >+ return result; >+} >+ >+} // namespace WebCore. >+ >+#endif // USE(GSTREAMER) >diff --git a/Source/WebCore/platform/graphics/gstreamer/mse/MediaPlayerPrivateGStreamerMSE.h b/Source/WebCore/platform/graphics/gstreamer/mse/MediaPlayerPrivateGStreamerMSE.h >new file mode 100644 >index 0000000000000000000000000000000000000000..040439f91e0dd3e8db3dfdbc16ab0e50c68c64e4 >--- /dev/null >+++ b/Source/WebCore/platform/graphics/gstreamer/mse/MediaPlayerPrivateGStreamerMSE.h >@@ -0,0 +1,130 @@ >+/* >+ * Copyright (C) 2007, 2009 Apple Inc. All rights reserved. >+ * Copyright (C) 2007 Collabora Ltd. All rights reserved. >+ * Copyright (C) 2007 Alp Toker <alp@atoker.com> >+ * Copyright (C) 2009, 2010, 2016 Igalia S.L >+ * Copyright (C) 2015 Sebastian Dröge <sebastian@centricular.com> >+ * Copyright (C) 2015, 2016 Metrological Group B.V. >+ * >+ * This library is free software; you can redistribute it and/or >+ * modify it under the terms of the GNU Library General Public >+ * License as published by the Free Software Foundation; either >+ * version 2 of the License, or (at your option) any later version. >+ * >+ * This library is distributed in the hope that it will be useful, >+ * but WITHOUT ANY WARRANTY; without even the implied warranty of >+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU >+ * Library General Public License for more details. >+ * >+ * You should have received a copy of the GNU Library General Public License >+ * aint with this library; see the file COPYING.LIB. If not, write to >+ * the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, >+ * Boston, MA 02110-1301, USA. >+ */ >+ >+#pragma once >+ >+#if ENABLE(VIDEO) && USE(GSTREAMER) && ENABLE(MEDIA_SOURCE) >+ >+#include "GRefPtrGStreamer.h" >+#include "MediaPlayerPrivateGStreamer.h" >+#include "MediaSample.h" >+#include "MediaSourceGStreamer.h" >+#include "PlaybackPipeline.h" >+#include "WebKitMediaSourceGStreamer.h" >+ >+namespace WebCore { >+ >+class MediaSourceClientGStreamerMSE; >+class AppendPipeline; >+class PlaybackPipeline; >+ >+class MediaPlayerPrivateGStreamerMSE : public MediaPlayerPrivateGStreamer { >+ WTF_MAKE_NONCOPYABLE(MediaPlayerPrivateGStreamerMSE); WTF_MAKE_FAST_ALLOCATED; >+ >+ friend class MediaSourceClientGStreamerMSE; >+ >+public: >+ explicit MediaPlayerPrivateGStreamerMSE(MediaPlayer*); >+ virtual ~MediaPlayerPrivateGStreamerMSE(); >+ >+ static void registerMediaEngine(MediaEngineRegistrar); >+ >+ void load(const String&) override; >+ void load(const String&, MediaSourcePrivateClient*) override; >+ >+ void setDownloadBuffering() override { }; >+ >+ bool isLiveStream() const override { return false; } >+ MediaTime currentMediaTime() const override; >+ >+ void pause() override; >+ bool seeking() const override; >+ void seek(float) override; >+ void configurePlaySink() override; >+ bool changePipelineState(GstState) override; >+ >+ void durationChanged() override; >+ MediaTime durationMediaTime() const override; >+ >+ void setRate(float) override; >+ std::unique_ptr<PlatformTimeRanges> buffered() const override; >+ float maxTimeSeekable() const override; >+ >+ void sourceChanged() override; >+ >+ void setReadyState(MediaPlayer::ReadyState); >+ void waitForSeekCompleted(); >+ void seekCompleted(); >+ MediaSourcePrivateClient* mediaSourcePrivateClient() { return m_mediaSource.get(); } >+ >+ void markEndOfStream(MediaSourcePrivate::EndOfStreamStatus); >+ >+#if ENABLE(LEGACY_ENCRYPTED_MEDIA) >+ void dispatchDecryptionKey(GstBuffer*) override; >+#endif >+ >+ void trackDetected(RefPtr<AppendPipeline>, RefPtr<WebCore::TrackPrivateBase> oldTrack, RefPtr<WebCore::TrackPrivateBase> newTrack); >+ void notifySeekNeedsDataForTime(const MediaTime&); >+ >+private: >+ static void getSupportedTypes(HashSet<String, ASCIICaseInsensitiveHash>&); >+ static MediaPlayer::SupportsType supportsType(const MediaEngineSupportParameters&); >+ >+ static bool isAvailable(); >+ >+ // FIXME: Reduce code duplication. >+ void updateStates() override; >+ >+ bool doSeek(gint64, float, GstSeekFlags) override; >+ bool doSeek(); >+ void maybeFinishSeek(); >+ void updatePlaybackRate() override; >+ void asyncStateChangeDone() override; >+ >+ // FIXME: Implement. >+ unsigned long totalVideoFrames() override { return 0; } >+ unsigned long droppedVideoFrames() override { return 0; } >+ unsigned long corruptedVideoFrames() override { return 0; } >+ MediaTime totalFrameDelay() override { return MediaTime::zeroTime(); } >+ bool isTimeBuffered(const MediaTime&) const; >+ >+ bool isMediaSource() const override { return true; } >+ >+ void setMediaSourceClient(Ref<MediaSourceClientGStreamerMSE>); >+ RefPtr<MediaSourceClientGStreamerMSE> mediaSourceClient(); >+ >+ HashMap<RefPtr<SourceBufferPrivateGStreamer>, RefPtr<AppendPipeline>> m_appendPipelinesMap; >+ bool m_eosMarked = false; >+ mutable bool m_eosPending = false; >+ bool m_gstSeekCompleted = true; >+ RefPtr<MediaSourcePrivateClient> m_mediaSource; >+ RefPtr<MediaSourceClientGStreamerMSE> m_mediaSourceClient; >+ MediaTime m_mediaTimeDuration; >+ bool m_mseSeekCompleted = true; >+ RefPtr<PlaybackPipeline> m_playbackPipeline; >+}; >+ >+} // namespace WebCore >+ >+#endif // USE(GSTREAMER)
You cannot view the attachment while viewing its details because your browser does not support IFRAMEs.
View the attachment on a separate page
.
View Attachment As Diff
View Attachment As Raw
Actions:
View
|
Formatted Diff
|
Diff
Attachments on
bug 162900
:
290592
|
290594
|
291763
|
292256
|
292409
|
292894