|
|
|
/*==LICENSE==*
|
|
|
|
|
|
|
|
CyanWorlds.com Engine - MMOG client, server and tools
|
|
|
|
Copyright (C) 2011 Cyan Worlds, Inc.
|
|
|
|
|
|
|
|
This program is free software: you can redistribute it and/or modify
|
|
|
|
it under the terms of the GNU General Public License as published by
|
|
|
|
the Free Software Foundation, either version 3 of the License, or
|
|
|
|
(at your option) any later version.
|
|
|
|
|
|
|
|
This program 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 General Public License for more details.
|
|
|
|
|
|
|
|
You should have received a copy of the GNU General Public License
|
|
|
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
|
|
|
|
Additional permissions under GNU GPL version 3 section 7
|
|
|
|
|
|
|
|
If you modify this Program, or any covered work, by linking or
|
|
|
|
combining it with any of RAD Game Tools Bink SDK, Autodesk 3ds Max SDK,
|
|
|
|
NVIDIA PhysX SDK, Microsoft DirectX SDK, OpenSSL library, Independent
|
|
|
|
JPEG Group JPEG library, Microsoft Windows Media SDK, or Apple QuickTime SDK
|
|
|
|
(or a modified version of those libraries),
|
|
|
|
containing parts covered by the terms of the Bink SDK EULA, 3ds Max EULA,
|
|
|
|
PhysX SDK EULA, DirectX SDK EULA, OpenSSL and SSLeay licenses, IJG
|
|
|
|
JPEG Library README, Windows Media SDK EULA, or QuickTime SDK EULA, the
|
|
|
|
licensors of this Program grant you additional
|
|
|
|
permission to convey the resulting work. Corresponding Source for a
|
|
|
|
non-source form of such a combination shall include the source code for
|
|
|
|
the parts of OpenSSL and IJG JPEG Library used as well as that of the covered
|
|
|
|
work.
|
|
|
|
|
|
|
|
You can contact Cyan Worlds, Inc. by email legal@cyan.com
|
|
|
|
or by snail mail at:
|
|
|
|
Cyan Worlds, Inc.
|
|
|
|
14617 N Newport Hwy
|
|
|
|
Mead, WA 99021
|
|
|
|
|
|
|
|
*==LICENSE==*/
|
|
|
|
|
|
|
|
#include "plMoviePlayer.h"
|
|
|
|
|
|
|
|
#include "hsConfig.h"
|
|
|
|
#ifdef PLASMA_USE_WEBM
|
|
|
|
# define VPX_CODEC_DISABLE_COMPAT 1
|
|
|
|
# include <vpx/vpx_decoder.h>
|
|
|
|
# include <vpx/vp8dx.h>
|
|
|
|
# define iface (vpx_codec_vp9_dx())
|
|
|
|
# include <opus.h>
|
|
|
|
|
|
|
|
# define WEBM_CODECID_VP9 "V_VP9"
|
|
|
|
# define WEBM_CODECID_OPUS "A_OPUS"
|
|
|
|
#endif
|
|
|
|
|
|
|
|
#include "hsResMgr.h"
|
|
|
|
#include "hsTimer.h"
|
|
|
|
#include "../plAudio/plWin32VideoSound.h"
|
|
|
|
#include "../plGImage/plMipmap.h"
|
|
|
|
#include "../pnKeyedObject/plUoid.h"
|
|
|
|
#include "../plPipeline/hsGDeviceRef.h"
|
|
|
|
#include "../plPipeline/plPlates.h"
|
|
|
|
#include "../plResMgr/plLocalization.h"
|
|
|
|
#include "../plStatusLog/plStatusLog.h"
|
|
|
|
#include "../plFile/plFileUtils.h"
|
|
|
|
|
|
|
|
#include "plPlanarImage.h"
|
|
|
|
#include "webm/mkvreader.hpp"
|
|
|
|
#include "webm/mkvparser.hpp"
|
|
|
|
|
|
|
|
#define SAFE_OP(x, err) \
|
|
|
|
{ \
|
|
|
|
Int64 ret = 0; \
|
|
|
|
ret = x; \
|
|
|
|
if (ret < 0) { \
|
|
|
|
hsAssert(false, "failed to " err); \
|
|
|
|
return false; \
|
|
|
|
} \
|
|
|
|
}
|
|
|
|
|
|
|
|
// =====================================================
|
|
|
|
|
|
|
|
class VPX
|
|
|
|
{
|
|
|
|
VPX() { }
|
|
|
|
|
|
|
|
#ifdef PLASMA_USE_WEBM
|
|
|
|
public:
|
|
|
|
vpx_codec_ctx_t codec;
|
|
|
|
|
|
|
|
~VPX()
|
|
|
|
{
|
|
|
|
if (vpx_codec_destroy(&codec))
|
|
|
|
hsAssert(false, vpx_codec_error_detail(&codec));
|
|
|
|
}
|
|
|
|
|
|
|
|
static VPX* Create()
|
|
|
|
{
|
|
|
|
VPX* instance = new VPX;
|
|
|
|
if (vpx_codec_dec_init(&instance->codec, iface, nil, 0)) {
|
|
|
|
hsAssert(false, vpx_codec_error_detail(&instance->codec));
|
|
|
|
delete instance;
|
|
|
|
return nil;
|
|
|
|
}
|
|
|
|
return instance;
|
|
|
|
}
|
|
|
|
|
|
|
|
vpx_image_t* Decode(UInt8* buf, UInt32 size)
|
|
|
|
{
|
|
|
|
if (vpx_codec_decode(&codec, buf, size, nil, 0) != VPX_CODEC_OK) {
|
|
|
|
const char* detail = vpx_codec_error_detail(&codec);
|
|
|
|
hsAssert(false, detail ? detail : "unspecified decode error");
|
|
|
|
return nil;
|
|
|
|
}
|
|
|
|
|
|
|
|
vpx_codec_iter_t iter = nil;
|
|
|
|
// ASSUMPTION: only one image per frame
|
|
|
|
// if this proves false, move decoder function into IProcessVideoFrame
|
|
|
|
return vpx_codec_get_frame(&codec, &iter);
|
|
|
|
}
|
|
|
|
#endif
|
|
|
|
};
|
|
|
|
|
|
|
|
// =====================================================
|
|
|
|
|
|
|
|
class TrackMgr
|
|
|
|
{
|
|
|
|
protected:
|
|
|
|
const mkvparser::Track* fTrack;
|
|
|
|
const mkvparser::BlockEntry* fCurrentBlock;
|
|
|
|
Int32 fStatus;
|
|
|
|
|
|
|
|
public:
|
|
|
|
TrackMgr(const mkvparser::Track* track) : fTrack(track), fCurrentBlock(nil), fStatus(0) { }
|
|
|
|
|
|
|
|
const mkvparser::Track* GetTrack() { return fTrack; }
|
|
|
|
|
|
|
|
bool GetFrames(mkvparser::MkvReader* reader, Int64 movieTimeNs, std::vector<blkbuf_t>& frames)
|
|
|
|
{
|
|
|
|
// If we have no block yet, grab the first one
|
|
|
|
if (!fCurrentBlock)
|
|
|
|
fStatus = fTrack->GetFirst(fCurrentBlock);
|
|
|
|
|
|
|
|
// Continue through the blocks until our current movie time
|
|
|
|
while (fCurrentBlock && fStatus == 0) {
|
|
|
|
const mkvparser::Block* block = fCurrentBlock->GetBlock();
|
|
|
|
Int64 time = block->GetTime(fCurrentBlock->GetCluster()) - fTrack->GetCodecDelay();
|
|
|
|
if (time <= movieTimeNs) {
|
|
|
|
// We want to play this block, add it to the frames buffer
|
|
|
|
frames.reserve(frames.size() + block->GetFrameCount());
|
|
|
|
for (Int32 i = 0; i < block->GetFrameCount(); i++) {
|
|
|
|
const mkvparser::Block::Frame data = block->GetFrame(i);
|
|
|
|
UInt8* buf = new UInt8[data.len];
|
|
|
|
data.Read(reader, buf);
|
|
|
|
frames.push_back(std::make_pair(std::auto_ptr<UInt8>(buf), static_cast<Int32>(data.len)));
|
|
|
|
}
|
|
|
|
fStatus = fTrack->GetNext(fCurrentBlock, fCurrentBlock);
|
|
|
|
} else {
|
|
|
|
// We've got all frames that have to play... come back for more later!
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return false; // No more blocks... We're done!
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
// =====================================================
|
|
|
|
|
|
|
|
plMoviePlayer::plMoviePlayer()
|
|
|
|
: fPlate(nil),
|
|
|
|
fTexture(nil),
|
|
|
|
fReader(nil),
|
|
|
|
fMovieTime(0),
|
|
|
|
fLastFrameTime(0),
|
|
|
|
fPosition(hsPoint2()),
|
|
|
|
fPlaying(false),
|
|
|
|
fPaused(false),
|
|
|
|
fMoviePath(nil)
|
|
|
|
{
|
|
|
|
fScale.Set(1.0f, 1.0f);
|
|
|
|
}
|
|
|
|
|
|
|
|
plMoviePlayer::~plMoviePlayer()
|
|
|
|
{
|
|
|
|
if (fPlate)
|
|
|
|
// The plPlate owns the Mipmap Texture, so it destroys it for us
|
|
|
|
plPlateManager::Instance().DestroyPlate(fPlate);
|
|
|
|
#ifdef PLASMA_USE_WEBM
|
|
|
|
if (fReader) {
|
|
|
|
fReader->Close();
|
|
|
|
delete fReader;
|
|
|
|
}
|
|
|
|
#endif
|
|
|
|
delete[] fMoviePath;
|
|
|
|
}
|
|
|
|
|
|
|
|
bool plMoviePlayer::IOpenMovie()
|
|
|
|
{
|
|
|
|
#ifdef PLASMA_USE_WEBM
|
|
|
|
if (!plFileUtils::FileExists(fMoviePath)) {
|
|
|
|
plStatusLog::AddLineS("movie.log", "%s: Tried to play a movie that doesn't exist.", fMoviePath);
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
// open the movie with libwebm
|
|
|
|
fReader = new mkvparser::MkvReader;
|
|
|
|
SAFE_OP(fReader->Open(fMoviePath), "open movie");
|
|
|
|
|
|
|
|
// opens the segment
|
|
|
|
// it contains everything you ever want to know about the movie
|
|
|
|
long long pos = 0;
|
|
|
|
mkvparser::EBMLHeader ebmlHeader;
|
|
|
|
SAFE_OP(ebmlHeader.Parse(fReader, pos), "read mkv header");
|
|
|
|
mkvparser::Segment* seg;
|
|
|
|
SAFE_OP(mkvparser::Segment::CreateInstance(fReader, pos, seg), "get segment info");
|
|
|
|
SAFE_OP(seg->Load(), "load segment from webm");
|
|
|
|
fSegment.reset(seg);
|
|
|
|
|
|
|
|
// Use first tracks unless another one matches the current game language
|
|
|
|
const mkvparser::Tracks* tracks = fSegment->GetTracks();
|
|
|
|
for (UInt32 i = 0; i < tracks->GetTracksCount(); ++i) {
|
|
|
|
const mkvparser::Track* track = tracks->GetTrackByIndex(i);
|
|
|
|
if (!track)
|
|
|
|
continue;
|
|
|
|
|
|
|
|
switch (track->GetType()) {
|
|
|
|
case mkvparser::Track::kAudio:
|
|
|
|
if (!fAudioTrack.get() || ICheckLanguage(track))
|
|
|
|
fAudioTrack.reset(new TrackMgr(track));
|
|
|
|
break;
|
|
|
|
case mkvparser::Track::kVideo:
|
|
|
|
if (!fVideoTrack.get() || ICheckLanguage(track))
|
|
|
|
fVideoTrack.reset(new TrackMgr(track));
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return true;
|
|
|
|
#else
|
|
|
|
return false;
|
|
|
|
#endif
|
|
|
|
}
|
|
|
|
|
|
|
|
bool plMoviePlayer::ILoadAudio()
|
|
|
|
{
|
|
|
|
#ifdef PLASMA_USE_WEBM
|
|
|
|
// Fetch audio track information
|
|
|
|
if (!fAudioTrack.get()) {
|
|
|
|
hsStatusMessage("Movie ILoadAudio fAudioTrack NIL\n");
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
const mkvparser::AudioTrack* audio = static_cast<const mkvparser::AudioTrack*>(fAudioTrack->GetTrack());
|
|
|
|
plWAVHeader header;
|
|
|
|
header.fFormatTag = plWAVHeader::kPCMFormatTag;
|
|
|
|
header.fNumChannels = audio->GetChannels();
|
|
|
|
header.fBitsPerSample = audio->GetBitDepth() == 8 ? 8 : 16;
|
|
|
|
header.fNumSamplesPerSec = 48000; // OPUS specs say we shall always decode at 48kHz
|
|
|
|
header.fBlockAlign = header.fNumChannels * header.fBitsPerSample / 8;
|
|
|
|
header.fAvgBytesPerSec = header.fNumSamplesPerSec * header.fBlockAlign;
|
|
|
|
fAudioSound.reset(new plWin32VideoSound(header));
|
|
|
|
|
|
|
|
// Initialize Opus
|
|
|
|
if (strncmp(audio->GetCodecId(), WEBM_CODECID_OPUS, arrsize(WEBM_CODECID_OPUS)) != 0) {
|
|
|
|
plStatusLog::AddLineS("movie.log", "%s: Not an Opus audio track!", fMoviePath);
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
int error;
|
|
|
|
OpusDecoder* opus = opus_decoder_create(48000, audio->GetChannels(), &error);
|
|
|
|
if (error != OPUS_OK)
|
|
|
|
hsAssert(false, "Error occured initalizing opus");
|
|
|
|
|
|
|
|
// Decode audio track
|
|
|
|
std::vector<blkbuf_t> frames;
|
|
|
|
fAudioTrack->GetFrames(fReader, fSegment->GetDuration(), frames);
|
|
|
|
static const int maxFrameSize = 5760; // for max packet duration at 48kHz
|
|
|
|
std::vector<Int16> decoded;
|
|
|
|
decoded.reserve(frames.size() * audio->GetChannels() * maxFrameSize);
|
|
|
|
|
|
|
|
Int16* frameData = new Int16[maxFrameSize * audio->GetChannels()];
|
|
|
|
for (std::vector<blkbuf_t>::iterator& frame = frames.begin(); frame != frames.end(); frame++) {
|
|
|
|
const std::auto_ptr<UInt8>& buf = frame->first;
|
|
|
|
Int32 size = frame->second;
|
|
|
|
|
|
|
|
int samples = opus_decode(opus, buf.get(), size, frameData, maxFrameSize, 0);
|
|
|
|
if (samples < 0)
|
|
|
|
hsAssert(false, "opus error");
|
|
|
|
for (size_t i = 0; i < samples * audio->GetChannels(); i++)
|
|
|
|
decoded.push_back(frameData[i]);
|
|
|
|
}
|
|
|
|
delete[] frameData;
|
|
|
|
|
|
|
|
fAudioSound->FillSoundBuffer(reinterpret_cast<UInt8*>(decoded.data()), decoded.size() * sizeof(Int16));
|
|
|
|
opus_decoder_destroy(opus);
|
|
|
|
return true;
|
|
|
|
#else
|
|
|
|
return false;
|
|
|
|
#endif
|
|
|
|
}
|
|
|
|
|
|
|
|
bool plMoviePlayer::ICheckLanguage(const mkvparser::Track* track)
|
|
|
|
{
|
|
|
|
#if defined(_MSC_VER) && _MSC_VER >= 1800
|
|
|
|
std::set<std::string> codes = plLocalization::GetLanguageCodes(plLocalization::GetLanguage());
|
|
|
|
if (codes.find(track->GetLanguage()) != codes.end())
|
|
|
|
return true;
|
|
|
|
#endif
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
void plMoviePlayer::IProcessVideoFrame(const std::vector<blkbuf_t>& frames)
|
|
|
|
{
|
|
|
|
#ifdef PLASMA_USE_WEBM
|
|
|
|
vpx_image_t* img = nil;
|
|
|
|
|
|
|
|
// We have to decode all the frames, but we only want to display the most recent one to the user.
|
|
|
|
for (std::vector<blkbuf_t>::const_iterator frame = frames.begin(); frame != frames.end(); frame++) {
|
|
|
|
const std::auto_ptr<UInt8>& buf = frame->first;
|
|
|
|
UInt32 size = static_cast<UInt32>(frame->second);
|
|
|
|
img = fVpx->Decode(buf.get(), size);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (img) {
|
|
|
|
// According to VideoLAN[1], I420 is the most common image format in videos. I am inclined to believe this as our
|
|
|
|
// attemps to convert the common Uru videos use I420 image data. So, as a shortcut, we will only implement that format.
|
|
|
|
// If for some reason we need other formats, please, be my guest!
|
|
|
|
// [1] = http://wiki.videolan.org/YUV#YUV_4:2:0_.28I420.2FJ420.2FYV12.29
|
|
|
|
switch (img->fmt) {
|
|
|
|
case VPX_IMG_FMT_I420:
|
|
|
|
plPlanarImage::Yuv420ToRgba(img->d_w, img->d_h, reinterpret_cast<const Int32*>(img->stride), img->planes, reinterpret_cast<UInt8*>(fTexture->GetImage()));
|
|
|
|
break;
|
|
|
|
|
|
|
|
default:
|
|
|
|
hsAssert(false, "image format");
|
|
|
|
}
|
|
|
|
|
|
|
|
// Flush new data to the device
|
|
|
|
if (fTexture->GetDeviceRef())
|
|
|
|
fTexture->GetDeviceRef()->SetDirty(true);
|
|
|
|
fPlate->SetVisible(true);
|
|
|
|
}
|
|
|
|
#endif
|
|
|
|
}
|
|
|
|
|
|
|
|
bool plMoviePlayer::Start()
|
|
|
|
{
|
|
|
|
if (fPlaying)
|
|
|
|
return false;
|
|
|
|
|
|
|
|
#ifdef PLASMA_USE_WEBM
|
|
|
|
if (!IOpenMovie())
|
|
|
|
return false;
|
|
|
|
hsAssert(fVideoTrack.get(), "nil video track -- expect bad things to happen!");
|
|
|
|
plStatusLog::AddLineS("movie.log", "%s: Start movie playback", fMoviePath);
|
|
|
|
hsStatusMessageF("Opened movie %s\n", fMoviePath);
|
|
|
|
|
|
|
|
// Initialize VPX
|
|
|
|
const mkvparser::VideoTrack* video = static_cast<const mkvparser::VideoTrack*>(fVideoTrack->GetTrack());
|
|
|
|
if (strncmp(video->GetCodecId(), WEBM_CODECID_VP9, arrsize(WEBM_CODECID_VP9)) != 0) {
|
|
|
|
plStatusLog::AddLineS("movie.log", "%s: Not a VP9 video track!", fMoviePath);
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
hsStatusMessageF("... movie track selected, codec: %s\n", video->GetCodecId());
|
|
|
|
|
|
|
|
if (VPX* vpx = VPX::Create())
|
|
|
|
fVpx.reset(vpx);
|
|
|
|
else
|
|
|
|
return false;
|
|
|
|
|
|
|
|
// Decode the audio track and load it into a sound buffer
|
|
|
|
if (!ILoadAudio()) {
|
|
|
|
hsStatusMessage("... movie audio track load failed\n");
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
hsStatusMessage("... movie audio track buffered\n");
|
|
|
|
|
|
|
|
|
|
|
|
fLastFrameTime = static_cast<Int64>(hsTimer::GetMilliSeconds());
|
|
|
|
fAudioSound->Play();
|
|
|
|
fPlaying = true;
|
|
|
|
|
|
|
|
hsStatusMessage("... movie start success\n");
|
|
|
|
return true;
|
|
|
|
#else
|
|
|
|
return false;
|
|
|
|
#endif // MOVIE_AVAILABLE
|
|
|
|
}
|
|
|
|
|
|
|
|
void plMoviePlayer::IInitPlate(UInt32 width, UInt32 height)
|
|
|
|
{
|
|
|
|
// Need to figure out scaling based on pipe size.
|
|
|
|
plPlateManager& plateMgr = plPlateManager::Instance();
|
|
|
|
float plateWidth = width * fScale.fX;
|
|
|
|
float plateHeight = height * fScale.fY;
|
|
|
|
if (plateWidth > plateMgr.GetPipeWidth() || plateHeight > plateMgr.GetPipeHeight()) {
|
|
|
|
float scale = std::min(plateMgr.GetPipeWidth() / plateWidth, plateMgr.GetPipeHeight() / plateHeight);
|
|
|
|
plateWidth *= scale;
|
|
|
|
plateHeight *= scale;
|
|
|
|
}
|
|
|
|
plateMgr.CreatePlate(&fPlate, fPosition.fX, fPosition.fY, 0, 0);
|
|
|
|
plateMgr.SetPlatePixelSize(fPlate, plateWidth, plateHeight);
|
|
|
|
fTexture = fPlate->CreateMaterial(width, height, false);
|
|
|
|
}
|
|
|
|
|
|
|
|
bool plMoviePlayer::NextFrame()
|
|
|
|
{
|
|
|
|
if (!fPlaying)
|
|
|
|
return false;
|
|
|
|
|
|
|
|
Int64 frameTime = static_cast<Int64>(hsTimer::GetMilliSeconds());
|
|
|
|
Int64 frameTimeDelta = frameTime - fLastFrameTime;
|
|
|
|
fLastFrameTime = frameTime;
|
|
|
|
|
|
|
|
if (fPaused)
|
|
|
|
return true;
|
|
|
|
|
|
|
|
#ifdef PLASMA_USE_WEBM
|
|
|
|
// Get our current timecode
|
|
|
|
fMovieTime += frameTimeDelta;
|
|
|
|
|
|
|
|
std::vector<blkbuf_t> video;
|
|
|
|
if (fVideoTrack.get() == nil || !fVideoTrack->GetFrames(fReader, fMovieTime * 1000000, video)) {
|
|
|
|
Stop();
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
// If the pipeline's device was invalidated, the plate will be invalid. Recreate now.
|
|
|
|
if (!fPlate) {
|
|
|
|
const mkvparser::VideoTrack* vt = static_cast<const mkvparser::VideoTrack*>(fVideoTrack->GetTrack());
|
|
|
|
IInitPlate(static_cast<UInt32>(vt->GetWidth()), static_cast<UInt32>(vt->GetHeight()));
|
|
|
|
hsAssert(fPlate, "failed to init plMoviePlayer plate -- bad things will happen!");
|
|
|
|
}
|
|
|
|
|
|
|
|
// Show our mess
|
|
|
|
IProcessVideoFrame(video);
|
|
|
|
fAudioSound->RefreshVolume();
|
|
|
|
|
|
|
|
return true;
|
|
|
|
#else
|
|
|
|
return false;
|
|
|
|
#endif // MOVIE_AVAILABLE
|
|
|
|
}
|
|
|
|
|
|
|
|
bool plMoviePlayer::Pause(bool on)
|
|
|
|
{
|
|
|
|
if (!fPlaying)
|
|
|
|
return false;
|
|
|
|
|
|
|
|
fAudioSound->Pause(on);
|
|
|
|
fPaused = on;
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
bool plMoviePlayer::Stop()
|
|
|
|
{
|
|
|
|
fPlaying = false;
|
|
|
|
if (fAudioSound.get() != nil)
|
|
|
|
fAudioSound->Stop();
|
|
|
|
if (fPlate)
|
|
|
|
fPlate->SetVisible(false);
|
|
|
|
|
|
|
|
for (std::vector<plMessage*>::iterator cb = fCallbacks.begin(); cb != fCallbacks.end(); cb++)
|
|
|
|
(*cb)->Send();
|
|
|
|
fCallbacks.clear();
|
|
|
|
return true;
|
|
|
|
}
|