ShortKitPlayer is the public interface for observing and controlling playback. You access it through the player property on your ShortKit instance. It exposes state via reactive publishers and accepts commands via direct method calls.
let player = shortKit.player
Publishers
Publishers emit values whenever the corresponding state changes. Subscribe to receive updates, or use the synchronous accessors for one-off reads.- iOS
- Android
- Flutter
- React Native
- Web
ShortKit uses Combine. Every publisher is typed as Store subscriptions in a
AnyPublisher<T, Never> — they never fail.import Combine
var cancellables = Set<AnyCancellable>()
shortKit.player.playerState
.receive(on: DispatchQueue.main)
.sink { state in
print("State: \(state)")
}
.store(in: &cancellables)
Set<AnyCancellable>. They are automatically cleaned up when the set is deallocated. Always dispatch to the main queue with .receive(on: DispatchQueue.main) before updating UI.ShortKit for Android uses Kotlin
StateFlow and SharedFlow. Collect from a coroutine scope tied to your lifecycle.lifecycleScope.launch {
shortKit.player.playerState.collect { state ->
println("State: $state")
}
}
ShortKit for Flutter exposes state via Store a reference to
ValueNotifier<T> on ShortKitController. Use ValueListenableBuilder for reactive UI, or read .value for one-off reads.final controller = ShortKit.of(context);
ValueListenableBuilder<PlayerState>(
valueListenable: controller.playerState,
builder: (context, state, _) {
return Text('State: $state');
},
)
ShortKitController via ShortKit.of(context). ValueNotifiers automatically notify listeners when their value changes.The React Native SDK exposes all player state reactively through the The hook returns a single object containing every publisher value and every command. Destructure what you need — your component re-renders when any included value changes.
useShortKitPlayer() hook. State updates trigger re-renders automatically.import { useShortKitPlayer } from '@shortkitsdk/react-native';
function MyOverlay() {
const { playerState } = useShortKitPlayer();
return <Text>{playerState}</Text>;
}
The Web SDK uses an event-based subscription model. Call
sk.player.on(event, callback) to subscribe and sk.player.off(event, callback) to unsubscribe.sk.player.on('stateChange', (state) => {
console.log('State:', state);
});
sk.player.on('timeUpdate', ({ current, duration, buffered }) => {
console.log('Progress:', current / duration);
});
sk.player.on('itemChange', (item) => {
console.log('Now playing:', item?.title);
});
Publisher reference
| Publisher | Type | Description |
|---|---|---|
time | PlayerTime | Fires at ~10 Hz while playing. Contains current, duration, and buffered. Values are in seconds on iOS and React Native, and milliseconds on Android. Use for scrubber UI and caption sync. |
playerState | PlayerState | Fires on every playback state transition. See Player state machine below. |
currentItem | ContentItem? | Metadata for the active content item. nil when no content is loaded. |
didLoop | LoopEvent | Fires each time the current video loops back to the beginning. |
feedTransition | FeedTransitionEvent | Fires at the start and end of a feed swipe navigation. |
formatChange | FormatChangeEvent | Fires when the HLS variant switches (resolution or bitrate change). |
isMuted | Bool | Current mute state. |
playbackRate | Float | Current playback speed (e.g. 1.0, 1.5, 2.0). |
captionsEnabled | Bool | Whether captions are currently enabled. |
activeCaptionTrack | CaptionTrack? | The currently selected caption track, or nil if none. |
activeCue | VTTCue? | The active caption cue at the current playback time, or nil between cues. |
prefetchedAheadCount | Int | Number of content items ahead that have preloaded thumbnails. Useful for building a “loading more” indicator. |
remainingContentCount | Int | Number of content items remaining ahead of the current position in the feed. |
feedScrollPhase | FeedScrollPhase | Current scroll state of the feed: .dragging(from: contentId) while the user is swiping, .settled when at rest. |
feedReady | Void | Fires once when the feed has loaded content and assigned the first player. Useful for hiding a loading screen. |
Synchronous accessors
For cases where you need a point-in-time read without setting up a subscription, two synchronous accessors are available.- iOS
- Android
- Flutter
- React Native
- Web
let item = shortKit.player.currentItemValue // ContentItem?
let rate = shortKit.player.playbackRateValue // Float
val item = shortKit.player.currentItemValue // ContentItem?
val rate = shortKit.player.playbackRateValue // Float
Every
ValueNotifier on ShortKitController provides synchronous access via .value:final controller = ShortKit.of(context);
final item = controller.currentItem.value; // ContentItem?
final rate = controller.playbackRate.value; // double
The React Native SDK does not have separate synchronous accessors. The
useShortKitPlayer() hook provides reactive state that is always current:import { useShortKitPlayer } from '@shortkitsdk/react-native';
const { currentItem, playbackRate } = useShortKitPlayer();
The Web SDK provides synchronous getters on the player object:
const state = sk.player.playerState; // 'idle'|'playing'|'paused'|...
const item = sk.player.currentItem; // ContentItem | null
const time = sk.player.time; // { current, duration, buffered }
const muted = sk.player.isMuted; // boolean
const rate = sk.player.playbackRate; // number
| Accessor | Type | Description |
|---|---|---|
currentItemValue | ContentItem? | Latest content item without subscribing. |
playbackRateValue | Float | Latest playback rate without subscribing. |
Commands
Commands are fire-and-forget methods that control playback. The SDK routes each command to the underlyingAVPlayer (iOS), ExoPlayer (Android), or native bridge (React Native).
- iOS
- Android
- Flutter
- React Native
- Web
shortKit.player.play()
shortKit.player.pause()
shortKit.player.seek(to: 15.0)
shortKit.player.seekAndPlay(to: 15.0)
shortKit.player.setMuted(false)
shortKit.player.skipToNext()
shortKit.player.skipToPrevious()
shortKit.player.setPlaybackRate(1.5)
shortKit.player.sendContentSignal(.positive)
shortKit.player.setMaxBitrate(2_000_000)
shortKit.player.setCaptionsEnabled(true)
shortKit.player.selectCaptionTrack(language: "en")
// Synchronous -- returns nil if storyboard is not yet loaded
let thumbnail: UIImage? = shortKit.player.seekThumbnail(at: 30.0)
shortKit.player.play()
shortKit.player.pause()
shortKit.player.seek(15.0)
shortKit.player.seekAndPlay(15.0)
shortKit.player.setMuted(false)
shortKit.player.skipToNext()
shortKit.player.skipToPrevious()
shortKit.player.setPlaybackRate(1.5f)
shortKit.player.sendContentSignal(ContentSignal.POSITIVE)
shortKit.player.setMaxBitrate(2_000_000)
shortKit.player.setCaptionsEnabled(true)
shortKit.player.selectCaptionTrack("en")
val thumbnail: Bitmap? = shortKit.player.seekThumbnail(30.0)
final controller = ShortKit.of(context);
controller.play();
controller.pause();
controller.seek(15.0);
controller.seekAndPlay(15.0);
controller.setMuted(false);
controller.skipToNext();
controller.skipToPrevious();
controller.setPlaybackRate(1.5);
controller.sendContentSignal('positive');
controller.setMaxBitrate(2000000);
controller.setCaptionsEnabled(true);
controller.selectCaptionTrack('en');
import { useShortKitPlayer } from '@shortkitsdk/react-native';
const {
play, pause, seek, seekAndPlay,
setMuted, skipToNext, skipToPrevious,
setPlaybackRate, sendContentSignal,
setMaxBitrate, setCaptionsEnabled,
selectCaptionTrack,
} = useShortKitPlayer();
play();
pause();
seek(15.0);
seekAndPlay(15.0);
setMuted(false);
skipToNext();
skipToPrevious();
setPlaybackRate(1.5);
sendContentSignal('positive');
setMaxBitrate(2_000_000);
setCaptionsEnabled(true);
selectCaptionTrack('en');
sk.player.play();
sk.player.pause();
sk.player.seek(15);
sk.player.setMuted(false);
sk.player.setPlaybackRate(1.5);
sk.player.setCaptionsEnabled(true);
sk.player.selectCaptionTrack('en');
sk.player.sendContentSignal('positive');
sk.player.skipToNext();
Command reference
| Method | Description |
|---|---|
play() | Resume playback. |
pause() | Pause playback. |
seek(to:) | Seek to a specific time in seconds. Does not auto-resume. |
seekAndPlay(to:) | Seek to a specific time and immediately resume playback. |
setMuted(_:) | Set the mute state. true mutes, false unmutes. |
skipToNext() | Navigate to the next item in the feed. |
skipToPrevious() | Navigate to the previous item in the feed. |
setPlaybackRate(_:) | Set playback speed. Common values: 1.0, 1.5, 2.0. |
sendContentSignal(_:) | Send a positive or negative signal about the current content. Used by the recommendation engine. |
setMaxBitrate(_:) | Set the maximum bitrate in bits per second. Pass 0 to uncap. |
setCaptionsEnabled(_:) | Enable or disable caption rendering. |
selectCaptionTrack(language:) | Select a caption track by BCP 47 language code (e.g. "en", "es"). |
seekThumbnail(at:) | Returns a storyboard thumbnail for the given time, or nil if the storyboard is not yet loaded. |
Player state machine
TheplayerState publisher emits one of these values:
public enum PlayerState: Equatable, Sendable {
case idle
case loading
case ready
case playing
case paused
case seeking
case buffering
case ended
case error(String)
}
| State | Meaning |
|---|---|
idle | No content loaded. Initial state before the feed provides a video. |
loading | Content URL received, HLS manifest is being fetched. |
ready | Enough data buffered to begin playback. Transitions here before auto-playing. |
playing | Video is actively playing. |
paused | Playback paused by the user or the SDK (e.g. app backgrounded). |
seeking | A seek operation is in progress. Returns to playing or paused when complete. |
buffering | Playback stalled waiting for data. Returns to playing when enough data arrives. |
ended | Video reached the end. For looping content, the SDK resets to playing and emits a didLoop event instead. |
error(String) | A playback error occurred. The associated string contains a description. If a fallback URL was provided, the SDK switches to the fallback MP4 and resumes playback instead of entering this state — only a playbackFallback analytics event is emitted. The player reaches error only when no fallback is available or the fallback has already been used. |
Typical transitions
idle -> loading -> ready -> playing -> paused -> playing (tap to pause/resume)
-> seeking -> playing (user scrubs)
-> buffering -> playing (network stall)
-> ended (non-looping content)
playing -> playing + didLoop event (looping content)
any state -> error (unrecoverable failure)
Player event types
PlayerTime
Emitted by thetime publisher at ~10 Hz during playback.
- iOS
- Android
- Flutter
- React Native
- Web
public struct PlayerTime: Equatable, Sendable {
public let current: Double // Current playback position in seconds
public let duration: Double // Total duration in seconds
public let buffered: Double // Buffered position in seconds
}
time.current / time.duration (guard against duration == 0).data class PlayerTime(
val currentMs: Long = 0, // Current playback position in milliseconds
val durationMs: Long = 0, // Total duration in milliseconds
val bufferedMs: Long = 0, // Buffered position in milliseconds
)
time.currentMs.toFloat() / time.durationMs (guard against durationMs == 0).class PlayerTime {
const PlayerTime({
required this.current, // Current playback position in seconds
required this.duration, // Total duration in seconds
required this.buffered, // Buffered position in seconds
});
final double current;
final double duration;
final double buffered;
}
time.current / time.duration (guard against time.duration == 0).interface PlayerTime {
current: number; // Current playback position in seconds
duration: number; // Total duration in seconds
buffered: number; // Buffered position in seconds
}
time.current / time.duration (guard against time.duration === 0).// Emitted via sk.player.on('timeUpdate', fn)
{
current: 12.5, // Current playback position in seconds
duration: 30.0, // Total duration in seconds
buffered: 20.0, // Buffered position in seconds
}
time.current / time.duration (guard against duration === 0).LoopEvent
Emitted by thedidLoop publisher each time a video restarts from the beginning.
public struct LoopEvent: Equatable, Sendable {
public let contentId: String // ID of the content that looped
public let loopCount: Int // Total number of completed loops
}
FeedTransitionEvent
Emitted by thefeedTransition publisher when the user swipes between feed items.
public struct FeedTransitionEvent: Equatable, Sendable {
public enum Phase: Sendable { case began, ended }
public enum Direction: Sendable { case forward, backward }
public let phase: Phase // .began when swipe starts, .ended when settled
public let from: ContentItem? // Item being swiped away
public let to: ContentItem? // Item being swiped to
public let direction: Direction // .forward (swipe up) or .backward (swipe down)
}
began phase fires as soon as the swipe gesture is recognized. The ended phase fires after the collection view settles on the new item. Use began to fade out a custom overlay and ended to configure it for the new item.
FormatChangeEvent
Emitted by theformatChange publisher when the HLS adaptive bitrate algorithm switches variants.
public struct FormatChangeEvent: Equatable, Sendable {
public let contentId: String
public let fromBitrate: Int // Previous bitrate in bps
public let toBitrate: Int // New bitrate in bps
public let fromResolution: String // e.g. "720x1280"
public let toResolution: String // e.g. "1080x1920"
}
FeedScrollPhase
Emitted by thefeedScrollPhase publisher to indicate the scroll state of the feed.
public enum FeedScrollPhase: Equatable, Sendable {
case dragging(from: String) // User is actively swiping; `from` is the content ID being swiped away
case settled // Feed is at rest on a cell
}
ContentSignal
Passed tosendContentSignal(_:) to influence the recommendation engine.
public enum ContentSignal: Equatable, Sendable {
case positive // User liked / saved / engaged positively
case negative // User reported / disliked
}
sendContentSignal accepts the string literals 'positive' or 'negative' instead of a typed enum.
Example: custom overlay with state observation
This example builds a minimal custom overlay that shows a play/pause button and a progress bar, driven entirely by the player’s publishers.- iOS
- Android
- Flutter
- React Native
- Web
import UIKit
import Combine
import ShortKitSDK
final class MinimalOverlay: UIView {
private let player: ShortKitPlayer
private var cancellables = Set<AnyCancellable>()
private let playPauseButton = UIButton(type: .system)
private let progressView = UIProgressView(progressViewStyle: .default)
private let titleLabel = UILabel()
init(player: ShortKitPlayer) {
self.player = player
super.init(frame: .zero)
setupUI()
bind()
}
required init?(coder: NSCoder) { fatalError() }
private func setupUI() {
// Layout code omitted for brevity -- add subviews and constraints
playPauseButton.addTarget(self, action: #selector(togglePlayPause), for: .touchUpInside)
}
private func bind() {
// Update play/pause icon based on state
player.playerState
.receive(on: DispatchQueue.main)
.sink { [weak self] state in
let icon = state == .playing ? "pause.fill" : "play.fill"
self?.playPauseButton.setImage(
UIImage(systemName: icon), for: .normal
)
}
.store(in: &cancellables)
// Drive progress bar from time publisher
player.time
.receive(on: DispatchQueue.main)
.sink { [weak self] time in
guard time.duration > 0 else { return }
self?.progressView.progress = Float(time.current / time.duration)
}
.store(in: &cancellables)
// Show title when content changes
player.currentItem
.receive(on: DispatchQueue.main)
.sink { [weak self] item in
self?.titleLabel.text = item?.title
}
.store(in: &cancellables)
}
@objc private func togglePlayPause() {
// Read current state, then issue the opposite command
player.playerState
.first()
.sink { [weak self] state in
if state == .playing {
self?.player.pause()
} else {
self?.player.play()
}
}
.store(in: &cancellables)
}
}
import android.content.Context
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.ProgressBar
import android.widget.TextView
import dev.shortkit.ShortKitPlayer
import dev.shortkit.PlayerState
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.collectLatest
class MinimalOverlay(
context: Context,
private val player: ShortKitPlayer,
private val scope: CoroutineScope
) : FrameLayout(context) {
private val playPauseButton = ImageButton(context)
private val progressBar = ProgressBar(context, null, android.R.attr.progressBarStyleHorizontal)
private val titleText = TextView(context)
init {
setupUI()
bind()
}
private fun setupUI() {
// Layout code omitted for brevity
playPauseButton.setOnClickListener { togglePlayPause() }
}
private fun bind() {
scope.launch {
player.playerState.collectLatest { state ->
val icon = if (state == PlayerState.PLAYING)
android.R.drawable.ic_media_pause
else
android.R.drawable.ic_media_play
playPauseButton.setImageResource(icon)
}
}
scope.launch {
player.time.collectLatest { time ->
if (time.duration > 0) {
progressBar.progress = ((time.current / time.duration) * 100).toInt()
}
}
}
scope.launch {
player.currentItem.collectLatest { item ->
titleText.text = item?.title ?: ""
}
}
}
private fun togglePlayPause() {
val current = player.playerState.value
if (current == PlayerState.PLAYING) player.pause() else player.play()
}
}
In Flutter, custom overlays are built as widgets registered via Register it in your overlay entry point:
ShortKitOverlayEngine.initialize(). The builder receives ContentItem, OverlayState, and can issue commands via ShortKitOverlayCommands.of(context).Widget minimalOverlay(BuildContext context, ContentItem item, OverlayState state) {
final commands = ShortKitOverlayCommands.of(context);
final progress = state.time.duration > 0
? state.time.current / state.time.duration
: 0.0;
return Positioned(
left: 0,
right: 0,
bottom: 0,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
item.title,
style: const TextStyle(color: Colors.white, fontSize: 16),
),
const SizedBox(height: 8),
LinearProgressIndicator(
value: progress,
backgroundColor: Colors.white24,
valueColor: const AlwaysStoppedAnimation(Colors.white),
),
const SizedBox(height: 12),
Center(
child: GestureDetector(
onTap: () {
if (state.playerState == PlayerState.playing) {
commands.pause();
} else {
commands.play();
}
},
child: Text(
state.playerState == PlayerState.playing ? '⏸' : '▶',
style: const TextStyle(color: Colors.white, fontSize: 24),
),
),
),
],
),
),
);
}
@pragma('vm:entry-point')
void shortKitOverlayMain() {
ShortKitOverlayEngine.initialize(
overlays: {'minimal': minimalOverlay},
);
}
import React from 'react';
import { View, Text, TouchableOpacity } from 'react-native';
import { useShortKitPlayer } from '@shortkitsdk/react-native';
export function MinimalOverlay() {
const {
playerState, time, currentItem,
play, pause,
} = useShortKitPlayer();
const progress = time.duration > 0 ? time.current / time.duration : 0;
return (
<View style={{ position: 'absolute', bottom: 0, left: 0, right: 0, padding: 16 }}>
<Text style={{ color: 'white', fontSize: 16, marginBottom: 8 }}>
{currentItem?.title}
</Text>
<View style={{ height: 3, backgroundColor: '#333', borderRadius: 1.5 }}>
<View style={{
height: 3,
width: `${progress * 100}%`,
backgroundColor: 'white',
borderRadius: 1.5
}} />
</View>
<TouchableOpacity
onPress={() => playerState === 'playing' ? pause() : play()}
style={{ marginTop: 12, alignSelf: 'center' }}
>
<Text style={{ color: 'white', fontSize: 24 }}>
{playerState === 'playing' ? '⏸' : '▶'}
</Text>
</TouchableOpacity>
</View>
);
}
app.js
const feed = sk.createFeed(document.getElementById('feed'), {
config: {
overlay: (container, { item, player, feed }) => {
// Title
const title = document.createElement('h2');
title.textContent = item.title;
title.style.cssText = 'color:#fff;position:absolute;bottom:80px;left:16px;';
container.appendChild(title);
// Progress bar
const track = document.createElement('div');
track.style.cssText = 'position:absolute;bottom:0;left:0;right:0;height:3px;background:#333;';
const fill = document.createElement('div');
fill.style.cssText = 'height:100%;background:#fff;width:0%;';
track.appendChild(fill);
container.appendChild(track);
player.on('timeUpdate', ({ current, duration }) => {
if (duration > 0) fill.style.width = `${(current / duration) * 100}%`;
});
// Play/pause button
const btn = document.createElement('button');
btn.textContent = 'Play';
btn.style.cssText = 'position:absolute;bottom:20px;left:50%;transform:translateX(-50%);color:#fff;';
btn.onclick = () => {
player.playerState === 'playing' ? player.pause() : player.play();
};
player.on('stateChange', (state) => {
btn.textContent = state === 'playing' ? 'Pause' : 'Play';
});
container.appendChild(btn);
},
},
});