Click here to Skip to main content
15,878,316 members
Articles / Multimedia / Video

Pure .NET DirectShow Filters in C#

Rate me:
Please Sign up or sign in to vote.
4.99/5 (78 votes)
13 Oct 2012CPOL16 min read 471.7K   37K   160   248
Article describes how to make DirectShow Filters in .NET, it consist of BaseClasses and couple of samples


In this post I describe how to make DirectShow filters in pure C#. I made the BaseClasses library also in pure C# and a few samples to show you how easily it can be used. I think beginner multimedia developers can use my library, but for extending it, you should have knowledge of COM, marshaling, and threading. I suggest you check my previous posts as I will not repeat major things described in there.

Content overview

The source code of filters consist two projects: BaseClasses and ExampleFilters. The BaseClasses library consists of these classes:

  • BaseEnum - base class for enumerations
  • EnumPins - implementation of IEnumPins interface.
  • EnumMediaTypes - implementation of IEnumMediaTypes interface.
  • BasePin - base class for pin.
  • BaseInputPin - base class for input pin.
  • BaseOutputPin - base class for output pin.
  • BaseFilter - base class for filter implementation.
  • TransformInputPin - transform filter input pin class.
  • TransformOutputPin - transform filter output pin class.
  • TransformFilter - transform filter base class.
  • TransInPlaceInputPin - trans-in-place filter input pin class.
  • TransInPlaceOutputPin - trans-in-place filter output pin class.
  • TransInPlaceFilter - trans-in-place filter base class.
  • RenderedInputPin - rendered input pin base class.
  • AMThread - Thread implementation base class.
  • ManagedThread - managed thread implementation of AMThread.
  • SourceStream - base output stream pin class.
  • BaseSourceFilter - base class for source filter implementation.
  • OutputQueue - class implements the queue to deliver media samples.
  • BasePropertyPage - class for implementation filter property page.
  • PosPassThru - class handles seek commands by passing them upstream to next filter.
  • RendererPosPassThru - class handles seek commands for renderer filters.
  • RendererInputPin - class implements input pin for BaseRendererFilter class.
  • MessageDispatcher - class handles unblocking notify from user thread and dispatching windows messages.
  • AsyncStream - System.IO.Stream implementation for IAsyncReader interface.
  • COMStream - System.IO.Stream implementation for IStream interface.
  • PacketData - base class for describing media data for queue.
  • PacketsQueue - queue class for PacketData objects.
  • BitStreamReader - helper class for reading binary data from System.IO.Stream object. It support IStream and IAsyncReader interfaces. Allows reading data by bits, by arrays, or by structures. Class also supports golomb SE and UE values. Reading performed via cache.
  • DemuxTrack - base class for implementation splitter track.
  • FileParser - base class for implementation file parsing support for splitters.
  • SplitterInputPin - class implements input pin for BaseSplitterFilter.
  • SplitterOutputPin - implementation of output pin for BaseSplitterFilter and BaseFileSourceFilter objects.
  • BaseSplitter - class implements core functionality for splitters or file sources implementation.
  • BaseSplitterFilter - base class for implementing splitter filter which has one input pin which supports connection to IAsyncReader, output pins created once it connected. Filter supports seeking.
  • BaseFileSourceFilter - base class for implementing file source filter, it has no input pins, but supports IFileSourceFilter interface, output pins created once file loaded.

Another project in the solution consists of these sample filters:

  • NullInPlaceFilter - in-place transform filter which can be used as base.
  • NullTransformFilter - transform filter which can be used as base.
  • DumpFilter - filter for saving incoming data into file.
  • TextOverFilter - transform filter display overlay text on incoming video.
  • ImageSourceFilter - push source filter which display loaded image file as a video stream.
  • ScreenCaptureFilter - push source filter which capture display and provide it as a video stream.
  • VideoRotationFilter - transform filter rotate video on 90 degree.
  • WavDestFilter - filter for saving incoming PCM data into a WAV file.
  • AudioChannelFilter - transform filter which combine incoming PCM audio data and send it to specified channel.
  • InfTeeFilter - filter delivers incoming samples to any connected output pins.
  • NullRendererFilter - filter implemented as standard "Null Renderer" filter, it inherited from base renderer filter and shows basic methods to override.
  • WAVESplitterFilter - filter implements splitter filter for WAVE files.
  • WAVESourceFilter - filter implements source filter for WAVE files.
  • NetworkSyncFilter and NetworkSource filters is an example how to implements multicast video steam.

Additionaly I include solution with the sample applications which show you how to use my helper classes and embedding filters into applications to use without registration:

  • DxCapture - example shows how to build video capture applications.
  • DxGrabber - an example of usage sample grabber.
  • DxPlayer - basic audio video player application.
  • WavCapture - example demonstrates how to build audio capture applications, sample embed wave out filter.
  • WavExtract - example shows how to extract audio data from media files and save them into WAVE format, sample embed wave out filter.
  • WavPlay - example of usage embeded WAVE source filter to play audio .wav files.


Base classes also use some stuff in my class library and COM helper objects. Those objects are partially described in my previous posts. For tracing and debugging I suggest to use the TRACE, TRACE_ENTER, and ASSERT functions; and do not use Debug.Write and throwing exceptions - why not is described in the previous article. Plus I suggest reading my description regarding threading in my previous post (just don't want to repeat it here). My implementation has similar methods as Microsoft native, so I'll describe only the differences. OK, if you are following all that aspects let's start reviewing.

Filter registration

The filter registering process is similar to a .NET COM object. The filter DLL should be signed. You can embed the type library as an unmanaged resource in the DLL but that's not mandatory. In sample filters the registration is performed automatically, see the install.bat and uninstall.bat files and the post build events. You can specify the registration in the project settings but that may not work. I made the filter registration very simple. For that there is a class attribute with different constructors:

// Class declaration
public class AMovieSetup : Attribute 
// Constructors
public AMovieSetup() 
public AMovieSetup(bool _register) 
public AMovieSetup(string _name) 
public AMovieSetup(Merit _merit) 
public AMovieSetup(Merit _merit, Guid _category) 
public AMovieSetup(string _name, Merit _merit) 
public AMovieSetup(string _name, Merit _merit, Guid _category) 

That attribute should be specified to the class which should be registered as a DirectShow filter. Class should be inherited from any base filter class, such as BaseFilter, TransInPlaceFilter, TransformFilter, or BaseSourceFilter. In attribute you can specify filter name filter merit and filter category. If name not specified then will be used name which is specified in a filter class. Default Merit value is DoNotUse, default registering category is AmLegacyFiltersCategory. Another mandatory attribute of the filter should be the Guid that will be CLSID of the filter. How performed registering and un-registering you can see in following routines of the BaseFilter class

public static void RegisterFunction(Type _type) 
public static void UnregisterFunction(Type _type)

Example of filter declaration:

// Here name not specified and will be used name setted in constructor 
// Category will be used by default AmLegacyFilters an merit - do not use 
[Guid("eeb3eef7-0592-491b-b7d4-8c65763c79c6")] // Filter CLSID 
[AMovieSetup(true)] // We should register 
public class NullInPlaceFilter : TransInPlaceFilter 
    public NullInPlaceFilter() 
    : base("CSharp Null InPlace Filter") {} // this name will be used 

Another difference from native BaseClasses is pin access. For used Pins property and initialization is OnInitializePins routine which is abstract in BaseFilter class.

protected abstract int OnInitializePins();

Along with pins initialization you can dynamically manipulation of the pins by next helper routines:

public int AddPin(BasePin _pin) 
public int RemovePin(BasePin _pin) 

How registered filters looks in GraphEdit:

Image 1

To make your own filter just inherit it from any base filter class specify required attributes and add functionality.

Customizing filters registration

In some cases may require to perform custom registration of the filters along with or without base registration. For this purpose have been added few methods. For example you may use that methods for registering file extensions for your splitter/source filter.

protected virtual int BeforeInstall(ref RegFilter2 _reginfo,ref IFilterMapper2 _mapper2)
    return NOERROR;

protected virtual int AfterInstall(HRESULT hr,ref RegFilter2 _reginfo, ref IFilterMapper2 _mapper2)
    return NOERROR;

protected virtual int BeforeUninstall(ref IFilterMapper2 _mapper2)
    return NOERROR;

protected virtual int AfterUninstall(HRESULT hr, ref IFilterMapper2 _mapper2)
    return NOERROR;

You can reconfigure _reginfo variable before registering filter and allocate memory for some fields in BeforeInstall method. And in AfterInstall method free that allocated memory. In case if BeforeInstall returns failure - filter does not register and AfterInstall not called. Into AfterInstall passed an HRESULT value of registration, so you can check if filter successfully registered and performs additional registration functionality.

Property Pages

Making filter property pages is also very simple. You should create Window Form and inherit it from BasePropertyPage class instead of Windows.Form. Form you created also require the Guid attribute as this is also COM object. BasePropertyPage class have same methods to override as base class from native BaseClasses. To assign your property page to a filter you should specify an attribute to your filter. This attribute looks:

public class PropPageSetup : Attribute

public PropPageSetup(Guid _guid)
public PropPageSetup(Guid _guid1,Guid _guid2)
public PropPageSetup(Guid _guid1,Guid _guid2,Guid _guid3)
public PropPageSetup(Type _type)
public PropPageSetup(Type _type1, Type _type2)
public PropPageSetup(Type _type1,Type _type2,Type _type3)

Attribute have couple of constructors. Example of specifying property page to a filter:

public class AudioChannelFilter : TransformFilter, IAudioChannel 

How property page looks for AudioChannelFilter:

Image 2

File Splitters and File sources implementation

In this part I briefly overview class library as there are no similar stuff in native BaseClasses, but that stuff will helps you to implement demultiplexors of existing formats or even make your own media file format. The base classes for demultiplexors are BaseSplitterFilter and BaseFileSourceFilter, which to choose depend on functionaly you needed. Mostly you not require to modify that base classes. But for your file format you should create at least 2 classes and inherit them from FileParser and DemuxTrack.

FileParser base class for performing file checking and initializing tracks. In it you should override at least two methods. CheckFile - in here you should validate that you can parse given file or stream. Note this method called for both file or stream implementation in case of usage file source first time will be called with just m_sFileName variable initialized and second time, in case if first time failed it called with m_Stream, which is object of BitStreamReader class. Another method to override is LoadTracks abstact - here you should initialize the tracks (your classes inherited from DemuxTrack) and put them into m_Tracks list. Also in that method you can initialize file duration in nanosecond UNITS (m_rtDuration variable). Parsing works in 2 models: single parsing thread model - there exists one main thread and in there performed demultiplexing, and multithreaded parsing model - here each pin takes care of parsing data. Note: accessing bit stream for reading data is thread safe. To specify which model to use specified boolean parameter in FileParser constructor - bRequireDemuxThread - true means single threading model (default). About that 2 models and how to decide which to you I planing to describe in other article, here just implementation overview.

Single thread model - to use that model you should override another 2 methods in FileParser class. SeekToTime - for support seeking - in that method you should set reading position according specified time. ProcessDemuxPackets - main method for demultiplexing data and put it into track queue. In here you should create PacketData object, fill it with data and put into DemuxTrack by calling AddToCache method. Note: once queue is full this method will block until samples will be delivered in filter. Note: in case critical unblocking to avoid hanging exists Reset method in DemuxTrack class, plus all threads automatically exits due setting quit event in BaseSplitter. But I not suggest you to use any of that manually - as this all handled in classes.

Multithreading model - in that model each track reading and delivering data and the queue may not be used. To implement that model you should override at least 2 methods in your class which inherited from DemuxTrack : GetNextPacket - which returns filled PacketData object or null in case of EOS, and SeekToTime - for same purpose as in single model but here seeking performed on each track directly.

DemuxTrack - base class for each track. You should override it GetMediaType abstract method. Other methods can be overridden according model you choose or specific track implementation.

PacketData - base class for specify media data for the track. It can consist of actual data or pointed to a file position. Anyway you can override this class for your own way providing media data, in that case you should override Dispose method to clear the resources and maybe FillSampleBuffer method in DemuxTrack class to fill media sample buffer from your class.

PacketsQueue - the samples queue class which used in single thread model. Class allows to add and remove PacketData object from queue and signal if queue is full or empty. Queue can sort samples by their timestamps - this used in case of you performing demultiplexing and decoding in your filter. Default cached size is 2 seconds - allocated once pin become active.

BitStreamReader - class for performing reading data from given stream. Helper class allows to thread safty reading data by objects, bits, bytes or golomb values.

In examples there are 2 filters shows you the basics of demultiplexors implementation: WAVESplitterFilter and WAVESourceFilter. But it simplify allows to make your own classes which I planing to more describe in next articles, for example I made Windows Media Splitter which shows in next picture (yes there are 4 streams in single file - was just operating with my own multiplexor - but splitter handle that without problems):

Image 3

Saving Filter Parameters in Registry

For better configuring filter from previously setted parameters there are exists two helper methods. They  allows write or read string or numeric parameters from system registry. Methods are located in BaseFilter class.

// Retrieve value from registry for current filter
protected object GetFilterRegistryValue(string _name,object _default)

// Set registry value for current filter
protected bool SetFilterRegistryValue(string _name,object _value)

Parameters are stored on filter registration registry subkey so it is personal filter data. Note: all setting are removed during filter unregistering.

Saving Filter Parameters in Graph File

Along with saving parameters into registry, filter allows to save and load it persistent data from saved graphs. Each filter supports IPersistStream interface, and have some helper methods to use it.

// Set or clear flag that properties are modified
protected HRESULT SetDirty(bool bDirty)
// Get's the size of persistent data
protected virtual long SizeMax()
// Write filter properties to the stream
protected virtual HRESULT WriteToStream(Stream _stream)
// Read filter properties from stream
protected virtual HRESULT ReadFromStream(Stream _stream)

To mark that the persistend data is modified you should use SetDirty method. An argument for loading and saving information is .NET type System.IO.Stream. Note: If you not override the SizeMax method then stream writing may be performed two times: first for calculating maximum output size, and second time for actual writing data. In your filter implementation you should override at least WriteToStream and ReadFromStream methods.

Embedding Filters Into Application

You maybe not know that filters may be created inside application and inserted to the filter graph. For that filter isn't registred in registry and only your application can use it. That is also possible with .NET. Make your filter with attribute of [ComVisible(false)] if your filter located in assembly which will be registered as a COM library, in application you may not use that attribute. Also we not require any other registration attributes, so all of them can be removed, even Guid attribute, as registration of the filter will not be called. Keep in mind that saving persistent data will not working in that case, including registry values. Property Pages if they are required should be registered as a COM, but I not sure that property page require for embedded filters as you can configure all settings manually in application.  Example of filter declaration in app:

public class WAVESourceFilter : BaseSourceFilterTemplate<WaveParser>
    public WAVESourceFilter()
        : base("CSharp WAVE Source")

Once filter class prepared you can create it as particular .NET object. As filter supports IBaseFilter interface you can insert it into Filter Graph and use it without problems.

public class WavPlayback : DSFilePlayback
    protected override HRESULT OnInitInterfaces()
        // Create Filter
        DSBaseSourceFilter _source = new DSBaseSourceFilter(new WAVESourceFilter());
        // load the file
        _source.FileName = m_sFileName;
        // Add to the filter Graph
        _source.FilterGraph = m_GraphBuilder;
        // Render the output pin
        return _source.OutputPin.Render();

In Sample Applications download there are 3 examples (WavCapture, WavExtract and WavPlay) which shows how to use embedded filters in application.

DirectShowNET Library

BaseClasses does not uses the DirectShowNET library, it consist of couple same interfaces and structures but marshaled differently. That is necessary and very important, because in a lot of cases we need IntPtr instead of actual interface due threading issues which I described in previous article. Accessing to the actual interfaces done via my magic class (also from previous post) VTableInterface. I modify it a little and add more functionality. You also can use DirectShowNET library in your filters but keep in mind ability of ambiguous issue. Instead of DirectShowNET library I put my directshow helper classes for easy way building filter graphs into this article as separate stuff for downloading.

Sample Filters


Example in-place transfor filter which doesn't modify data and can be used as start point of development your filters.


Example transform filter which just copy media samples without modifications. This example can be used as start point of developing your own transform filters.


Image 4

Example filter which performs writing incoming data into specified file. Filter supports IFileSinkFilter and IAMFilterMiscFlags. Filter accept any incoming types.


Image 5

Example of transform filter which demonstrates how to draw text on the video stream. Accepted Media Type RGB32, filter not performing pitch correction and will be connected to the video renderer via ColorSpace Converter.


Image 6

Example of transform filter which performing rotation of the video on 90 degree. Accepted Media Type RGB24, filter not performing pitch correction and will be connected to the video renderer via ColorSpace Converter.


Image 7

Example of implementation push source filter which provides loaded image file as a video stream. Filter supports IFileSourceFilter interface. Output Width and Height are set according loaded image resolutions. Default FPS is 20. Output media format is RGB32. Filter not performs pitch correction and will be connected to the video renderer via ColorSpace Converter.


Image 8

Example of push source filter provide copy of current desktop image via GDI. Output Width and Height set to 640x480 and filter performs resizing to fit that resolution. Default FPS is 20.Output media format RGB32. Filter not performs pitch correction and will be connected to the video renderer via ColorSpace Converter.


Image 9

Example of the filter which writes an audio stream to a WAV file. Filter supports IFileSinkFilter and IAMFilterMiscFlags. Filter accept PCM media type and WaveFormatEx format type.


Image 10

Example of transform filter which performs an audio output to specified channel. Filter support PCM input of WAVE_FORMAT_PCM format and BitsPerSample 8 or 16 bits. Output media format is WaveFormatExtensible with one channel output and speaker configuration. Filter supports custom interface by using it you can specify the output channel.

public enum AudioChannel : int 
    FRONT_LEFT = 0x1, 
    FRONT_RIGHT = 0x2, 
    FRONT_CENTER = 0x4, 
    LOW_FREQUENCY = 0x8, 
    BACK_LEFT = 0x10, 
    BACK_RIGHT = 0x20, 
    SIDE_LEFT = 0x200, 
    SIDE_RIGHT = 0x400, 
public interface IAudioChannel 
    int put_ActiveChannel([In] AudioChannel _channel); 
    int get_ActiveChannel([Out] out AudioChannel _channel); 


Image 11

Example is similar to Microsoft InfTee sample DirectShow filter.

Null Renderer Filter

Image 12

Filter inherited from BaseRendererFilter and works same way as standard "Null Renderer" - by discarding every samples it receives without displaying or rendering them.

WAVE Splitter Filter

Image 13

Splitter filter example which parse WAVE file data and delivery it to downstream filter. Filter have one input pin and support for connection to pin with IAsyncReader interface: such as "File Source Async" filter. Output pin created once input pin is connected. Filter does not registered for usage automatically, so you should build graph manually.

WAVE Source Filter

Image 14

Example of source filter which parse WAVE file data and delivery it to downstream filter. Filter suppots IFileSourceFilter and have no input pins. Output pin created once input pin is connected. Filter does not registered for usage automatically, so you should build graph manually.

Network Sync and Source Filters

Image 15

Example of 2 filters: sender and receiver which performs multicast a video data compressed into JPEG. To one Sync filter can be connected few source filters. Sync filter inherited from BaseRendrerFilter and supports RGB24 input. Once graph become active sync filter start multicast incoming samples. Multicast settings can be configured via INetworkConfig interface.

public interface INetworkConfig
    string IP { get; set; }
    int Port { get; set; }

Interface allows to specify multicast IP address and port. In GraphEdit you may use property page for it.

Image 16

Source filter start waiting for a samples in separate thread once it added into filter graph. MediaType on output pin available only if at least one sample heve beed received. Filter also supports INetworkConfig interface. Note filter may not work on high resolutions due data to send may be large and not supported on system level.

Advanced samples

More sample filters I posting as separate articles as they require to review the code. For now available next samples:


  • 13-07-2012 - Initial version.
  • 15-07-2012 - Added property pages, OutputQueue and InfTeeFilter sample.
  • 09-08-2012 - Made some improvements.
  • 14-08-2012 - Implemented BaseRenderer, BaseSplitter and BaseFileSource, added couple of samples.
  • 19-09-2012 - Added application samples.
  • 13-10-2012 - Solved .NET Framework 2.0 Issue with missed methods. Modified class library due some other fixes.


This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)

Written By
Software Developer (Senior)
Russian Federation Russian Federation
I'm a professional multimedia developer (more than 10 years) in any kind of applications and technologies related to it, such as DirectShow, Direct3D, WinMM, OpenGL, MediaFoundation, WASAPI, Windows Media and other including drivers development of Kernel Streaming, Audio/Video capture drivers and audio effects. Have experience in following languages: C, C++, C#, delphi, C++ builder, VB and VB.NET. Strong knowledge in math and networking.

Comments and Discussions

QuestionChange FPS with TextOverFilter Pin
dmg2524-Jan-21 2:30
dmg2524-Jan-21 2:30 
QuestionBug : TransInPlaceFilter.UsingDifferentAllocators always returns true Pin
nujec4-Jun-20 3:22
nujec4-Jun-20 3:22 
QuestionHow to register sample filters Pin
Andy Rama7-Jun-19 7:43
Andy Rama7-Jun-19 7:43 
QuestionI wanted to get your input on how it would be possible? (if at all).Because it impacts on system level internet connection not the very app level. Pin
Sunil Kumar2-Oct-17 5:30
Sunil Kumar2-Oct-17 5:30 
QuestionProfessional held required Pin
Member 997523124-Jul-17 21:23
Member 997523124-Jul-17 21:23 
QuestionDevelop a Live Source Filter for any input type frame Pin
ykarbaschi31-May-17 11:42
ykarbaschi31-May-17 11:42 
QuestionHow to get input from webcam or other devices Pin
azaran200218-Sep-16 20:30
azaran200218-Sep-16 20:30 
QuestionSkipping frames in an MJPEG WebCam stream Pin
Member 1216720718-Aug-16 8:32
Member 1216720718-Aug-16 8:32 
QuestionGetCurrentPosition from TransformFilter.Transform method Pin
darko798-Aug-16 22:31
darko798-Aug-16 22:31 
QuestionHow can we use your filters as windows media player defaults ? Pin
Member 1248286416-Jul-16 21:21
Member 1248286416-Jul-16 21:21 
QuestionConvert project in .NET framework version 4.0 failed Pin
Amogh Shah6-May-16 21:28
Amogh Shah6-May-16 21:28 
AnswerRe: Convert project in .NET framework version 4.0 failed Pin
Software_Magic4-Jan-18 10:02
Software_Magic4-Jan-18 10:02 
AnswerRe: Convert project in .NET framework version 4.0 failed Pin
artur.p5-Mar-18 3:05
artur.p5-Mar-18 3:05 
QuestionGetting error while adding filter in GraphEdit application Pin
vijay kharde11-Apr-16 9:21
vijay kharde11-Apr-16 9:21 
QuestionI want to grab picture between WebCam and Skype Pin
ikeda.shogouki18-Dec-15 23:57
ikeda.shogouki18-Dec-15 23:57 
QuestionGreat Article Pin
Member 359856721-Nov-15 11:55
Member 359856721-Nov-15 11:55 
QuestionTake CamShot without having a rendering window. Pin
Shivendra_130-Jul-15 19:46
professionalShivendra_130-Jul-15 19:46 
AnswerRe: Take CamShot without having a rendering window. Pin
Maxim Kartavenkov18-Aug-15 20:54
Maxim Kartavenkov18-Aug-15 20:54 
QuestionConnecting filter to EVR Presenter Pin
Wayne Work 21116-Apr-15 5:21
Wayne Work 21116-Apr-15 5:21 
AnswerRe: Connecting filter to EVR Presenter Pin
Maxim Kartavenkov30-Jun-15 23:52
Maxim Kartavenkov30-Jun-15 23:52 
GeneralThis is unbelievably, incredibly, awesome stuff! Pin
schungx20-Mar-15 0:02
schungx20-Mar-15 0:02 
GeneralRe: This is unbelievably, incredibly, awesome stuff! Pin
Maxim Kartavenkov31-Mar-15 8:33
Maxim Kartavenkov31-Mar-15 8:33 
QuestionIncredible Stuff!! Pin
andrew_nz21-Feb-15 11:43
andrew_nz21-Feb-15 11:43 
GeneralRe: Incredible Stuff!! Pin
Maxim Kartavenkov31-Mar-15 8:32
Maxim Kartavenkov31-Mar-15 8:32 
QuestionDoes anyone know where I get the DirectShow and Sonic units from? Pin
roscler14-Feb-15 18:07
professionalroscler14-Feb-15 18:07 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.