-
Notifications
You must be signed in to change notification settings - Fork 1
Advanced 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.
GetSideStripProvidersis still called, but the providers simply never get a session on a device without strips. Guard nothing — just return your providers.
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;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.
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.
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.
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
RenderStripapplies 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 getWidth/Heightfor the whole strip per call — note whenRenderSegmentis invoked (it implies stacked, one-per-segment layout) so tap hit-testing uses the right axis.
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).
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.
Getting started
API reference
Advanced
Operations
Release notes