Skip to content

Advanced Side Strips

RadiatorTwo edited this page Jun 14, 2026 · 1 revision

Side Strips

A side-strip device — the Razer Stream Controller — has two narrow display strips, one down each side of the central touch grid, next to the dial columns. On the Razer each strip is a 60×270 region of the single panel, with three dials beside it. The host can render those strips in three modes:

  • Segmented (default) — each strip shows its three dials' labels/values, stacked top to bottom.
  • Free-draw — a per-page canvas the user draws in the editor.
  • Plugin-override — a plugin owns the strip's pixels and its tap/swipe gestures.

This page covers the plugin-override and segment-override paths. Unlike Exclusive Mode, the rest of the device keeps working while a strip is plugin-driven: the three side dials still run their commands, and paging still works — the provider only owns the 60×270 strip region (or one 60×90 segment) and the gestures on it.

All rendering goes through IRenderCanvas; a provider never returns bytes.

Razer-only. The Loupedeck Live S has no side strips. GetSideStripProviders is still called, but the providers simply never get a session on a device without strips. Guard nothing — just return your providers.

Contributing a provider

Override GetSideStripProviders on your LoupixPlugin and return one or more ISideStripProvider. The host collects them after Initialize, alongside GetCommands, and lists them in the strip-mode editor's provider picker when the user sets a strip to plugin-override.

public override IEnumerable<ISideStripProvider> GetSideStripProviders() => _stripProviders;

ISideStripProvider

public interface ISideStripProvider
{
    string Id    { get; }   // stable, persisted in the page binding — use "{pluginId}.{name}"
    string Title { get; }   // shown in the provider picker
    ISideStripSession CreateSession(SideStripContext context);
}

The provider is a stateless descriptor + factory. The host calls CreateSession once per attachment — and a strip is attached per side, so the same provider can drive the left and right strip simultaneously, each with its own session and context. Id is persisted in the page binding; if the plugin is later uninstalled the binding is preserved but falls back to segmented, so keep Id stable across releases.

SideStripContext

Handed to CreateSession. Carries the geometry, the adjacent dials' bindings, and side-bound paging callbacks so a session can page its own column without knowing device geometry.

public sealed class SideStripContext
{
    public StripSide Side   { get; init; }   // Left or Right
    public int       Width  { get; init; }   // 60 on the Razer
    public int       Height { get; init; }   // 270 on the Razer
    public IReadOnlyList<SideStripRotary> Rotaries { get; init; } // 3, top-to-bottom
    public Action RequestNextPage     { get; init; }  // advance this side's rotary page
    public Action RequestPreviousPage { get; init; }  // go back one page
}

Rotaries lets a provider reflect what each knob actually controls. Each SideStripRotary exposes the dial's static Label and its bound LeftCommand / RightCommand / PressCommand strings — so an audio provider can parse a device id out of the bound command and show that endpoint's level, staying in sync with the dial assignment without separate configuration.

public sealed class SideStripRotary
{
    public int    Index        { get; init; }  // 0-based within the side
    public string Label        { get; init; }
    public string LeftCommand  { get; init; }
    public string RightCommand { get; init; }
    public string PressCommand { get; init; }
}

RequestNextPage / RequestPreviousPage are safe to call from any thread.

ISideStripSession

public interface ISideStripSession : IDisposable
{
    bool RenderStrip(IRenderCanvas canvas);     // 60×270; true = drawn, false = host segmented fallback
    event EventHandler StripChanged;            // request a redraw (may fire from any thread)
    void OnStripTapped(int x, int y);           // strip-local coords (0..Width, 0..Height)
    void OnStripSwiped(StripSwipeDirection direction); // Up / Down
}

One live attachment to a single side. Holds the per-side state (geometry, the dials' bindings, event subscriptions).

Member Notes
RenderStrip(canvas) Draw the whole strip and return true; return false to let the host render the default segmented dial labels for that frame.
StripChanged Raise when your output has changed and the host should redraw. May be raised from a background thread — the host serializes and rate-limits the resulting redraws, so raise it freely on every data change.
OnStripTapped(x, y) A tap landed; coordinates are strip-local. Hit-test it yourself against your layout.
OnStripSwiped(direction) A vertical swipe. Decide whether to page (context.RequestNextPage() / RequestPreviousPage()) or consume it.

The host disposes the session on detach — navigating away, full-device takeover, device-off, or plugin unload. Release timers and event subscriptions in Dispose; a session that keeps a subscription alive can pin a collectible load context and leak the whole plugin.

Segment override (segmented mode)

A provider can also light up a single segment while the host renders the others — e.g. the volume band of an audio dial, mixed with normal labels on the other two dials of that side. Implement the marker ISegmentStripProvider on the provider and ISegmentStripSession on its session:

public interface ISegmentStripProvider : ISideStripProvider { }   // marker only

public interface ISegmentStripSession
{
    bool RenderSegment(int rotaryIndex, IRenderCanvas canvas);  // 60×90; false = host draws that label
}

rotaryIndex is 0-based within the side, top-to-bottom, matching SideStripContext.Rotaries. Return false for a segment you don't own so the host draws that dial's normal label. Live updates, taps and disposal reuse the ISideStripSession members (StripChanged, OnStripTapped, Dispose) — there is no separate segment session lifecycle.

Segment override applies in the host's segmented mode; whole-strip RenderStrip applies in plugin-override mode. A provider can implement both and the host picks the path for the strip's current mode. A common pattern: in segmented mode you don't get Width/Height for the whole strip per call — note when RenderSegment is invoked (it implies stacked, one-per-segment layout) so tap hit-testing uses the right axis.

Example: audio volume bars

The Audio plugin's provider implements both interfaces. The session seeds each bar from the device its dial controls (parsed from the bound command), subscribes to live volume changes, and raises StripChanged on every update:

internal sealed class AudioVolumeStripProvider(IAudioService audio, IPluginSettings settings)
    : ISideStripProvider, ISegmentStripProvider
{
    public string Id    => "audio.volume-bars";
    public string Title => "Audio Volume Bars";

    public ISideStripSession CreateSession(SideStripContext context) =>
        new AudioVolumeStripSession(audio, settings, context);
}

internal sealed class AudioVolumeStripSession : ISideStripSession, ISegmentStripSession
{
    private readonly SideStripContext _context;
    public event EventHandler? StripChanged;

    public AudioVolumeStripSession(IAudioService audio, IPluginSettings settings, SideStripContext context)
    {
        _context = context;
        foreach (var rotary in context.Rotaries)
        {
            // Resolve the device this dial controls from its bound command…
            var deviceId = AudioStripCommandParser.ExtractDeviceId(rotary);
            // …seed the bar and subscribe; raise StripChanged on every change.
            audio.SubscribeVolumeChanges(deviceId, (vol, mute) => StripChanged?.Invoke(this, EventArgs.Empty));
        }
    }

    // Whole strip (plugin-override mode): three side-by-side vertical bars.
    public bool RenderStrip(IRenderCanvas canvas)
    {
        if (/* nothing audio-related on this side */ false) return false;   // → host draws labels
        // …draw bars with canvas primitives…
        return true;
    }

    // One segment (segmented mode): just this dial's band.
    public bool RenderSegment(int rotaryIndex, IRenderCanvas canvas)
    {
        // return false when this dial controls no audio device → host draws its label
        // …draw the single band…
        return true;
    }

    public void OnStripTapped(int x, int y) { /* hit-test → toggle mute */ }

    public void OnStripSwiped(StripSwipeDirection direction)
    {
        if (direction == StripSwipeDirection.Up) _context.RequestNextPage();
        else _context.RequestPreviousPage();
    }

    public void Dispose() { /* dispose every volume-change subscription */ }
}

See AudioVolumeStripProvider.cs in the Audio plugin for the complete implementation (bar layout, ellipsizing labels with MeasureText, the horizontal/segment layout toggle).

When you only need to know one thing

Return your providers from GetSideStripProviders, draw the strip in RenderStrip (or a segment in RenderSegment) with IRenderCanvas, raise StripChanged when your data moves, and dispose every subscription in Dispose.

Clone this wiki locally