Shoplive Developers
한국어KOEnglishEN日本語JA

Shoplive V1 Mobile SDK Integration Guide

Getting started

Overview & Requirements

The Shoplive V1 mobile SDK integrates live commerce viewing (Player) to your app. The SDK handles streaming, chat, and overlays; your app only has to open the broadcast and then just wire up users, products, and coupons.
It covers opening a fullscreen live stream with one tap, entry via deep link, keeping playback in a small window when the user navigates away (PIP), product and coupon integration, and purchase conversion tracking. You don't have to wire up all of it: just pick the missions you actually need and apply those.

Get started →

PlatformMinimumRecommendedLatest version
iOSiOS 15 · Swift 5 · CocoaPods 1.9 or later—1.9.0
AndroidAndroid 4.4(API 19). shoplive-media3 requires Android 6.0 (API 23)Playback engine built in (no separate ExoPlayer install · declare just one engine module from Install)1.9.0
FlutterDart SDK >=2.17.1 <4.0.0 · Flutter >=2.5.0 · iOS 15.0 · Android minSdk 19 · JDK 17—1.9.01
  1. Flutter shoplive_player 1.9.0 bundles iOS · Android SDK 1.9.0 (ExoPlayer 2.19.1.20). The plugin builds with compileSdk 36 · jvmToolchain 17, so Android builds need JDK 17.

The Android SDK builds with compileSdk · targetSdk 36. The minimum your app needs is unconfirmed (needs verification).

Check these before you pick a version
  • If you install iOS via SPM, pin the version to 1.9.0. V1 and V3 share the same distribution repository, so V3 (3.x) gets installed if you leave the version open. See Install.
  • If you use Flutter, you also have to meet the iOS and Android requirements in the table above. The Flutter plugin is a thin wrapper around the native SDKs, so their requirements carry straight through.
  • Upgrading from Flutter 1.8.x? Check your shortform code. shoplive_player 1.9.0 drops the shortform API and the shortform SDK dependency. Remove any code that used the shortform API in 1.8.x before you upgrade.
  • If your Xcode is older than 26, installing or building the official release may fail. Backward-compatible builds for that case are listed in the Platform appendix.
1.9.0 differs from 1.8.x in several ways, including raising the iOS Deployment Target to 15.0. Before upgrading, read the check the 1.9.0 release notes first.

Module composition

The SDK is split by what each part does. Decide which roles you need first, then find the actual names you install at the bottom of each card. On Flutter a single package wraps all of them, so there is nothing to choose.

Mission map

Find what you want to build below and jump straight to that mission. You don't have to read them in order, but every mission assumes you have finished Install and Mission 1.

What you want to buildMission
Tapping a button opens the live stream in full screenMission 1 · Launch the player
Tapping a push/SMS link opens the live stream directlyMission 2 · Launch via deep link
Let signed-in members watch (guest · simple · JWT)Mission 3 · Connect user information
Let viewers chatMission 4 · Chat
Issue coupons during the broadcastMission 5 · Coupons
Send users to your product detail page when they tap a productMission 6 · Product clicks
Let viewers share the broadcast linkMission 7 · Share the broadcast link
Keep the player visible in a small window when the user leaves the screenMission 8 · PIP
Show a preview before or during the broadcastMission 9 · Broadcast preview
Measure purchase conversionMission 10 · Purchase conversion tracking
Match colors and behavior to your appMission 11 · UI and behavior customization

Next → Install

Getting started

Install

GoalAdd the Shoplive SDK to your project and get the build passing
PrerequisitesAn iOS, Android or Flutter project that builds. Exact per-platform versions are in the requirements table

Read only the section for the platform you use. On iOS, pick CocoaPods and Swift Package Manager — choose only one, then follow the matching tab below.

iOS installation (latest: 1.9.0)

The Shoplive iOS SDK isn't distributed through the official CocoaPods CDN, but through a private spec repository. In your Podfile, you must write source — both lines.

# Podfile
source 'https://github.com/shoplive/pod-specs.git'   # Shoplive spec repo
source 'https://cdn.cocoapods.org/'                  # official CocoaPods CDN

platform :ios, '15.0'
use_frameworks!

target 'YourApp' do
  pod 'ShopLive', '1.9.0'
  pod 'ShopliveSDKCommon', '1.9.0'
end
pod install --repo-update
You must write both source lines The moment you write source even once in your Podfile, the default CDN is no longer applied automatically. If you forget the official CDN line, installation of every other pod besides the Shoplive SDK will fail.
Watch the pod name The README in the Shoplive spec repository tells you to use pod 'ShopliveSDK', but that name doesn't actually exist in the spec. Only ShopLive·ShopliveSDKCommon·ShopliveShortformSDK exist, so write it as ShopLive as in the example above.

As of 1.9.0, the SPM distribution repository is unified into shoplive-sdk-ios as of 1.9.0. Up to 1.8.x you had to pull ios-sdk and common-ios from two separate repositories, but for a new integration the unified repository is all you need.

  1. In Xcode, open File → Add Package Dependencies… and enter https://github.com/shoplive/shoplive-sdk-ios.
  2. Set the dependency rule to Up to Next Major Version, with base version 1.9.0.
  3. ShopLiveSDK·ShopliveSDKCommon: add both to your app target.
dependencies: [
    .package(url: "https://github.com/shoplive/shoplive-sdk-ios", .upToNextMajor(from: "1.9.0"))
],
targets: [
    .target(
        name: "YourApp",
        dependencies: [
            .product(name: "ShopLiveSDK", package: "shoplive-sdk-ios"),
            .product(name: "ShopliveSDKCommon", package: "shoplive-sdk-ios")
        ]
    )
]
V1 and V3 share this repository, so you must state the version main branch holds the 3.x (V3) manifest, and 1.x is only on the release/1.9.0 branches/tags. .upToNextMajor(from: "1.9.0") is an example of how you must pin the major version, so it doesn't accidentally jump to 3.x. When adding it through the Xcode UI too, set the rule to "Up to Next Major Version" + 1.9.0.

Android Install

Shoplive libraries are not on Maven Central; they live in a repository Shoplive runs itself. So before you declare the dependencies, register the repository first. Skip this step and Gradle cannot find the libraries, so the sync fails.

// settings.gradle (dependencyResolutionManagement) or the project build.gradle
repositories {
    maven { url 'https://sdk.shoplive.cloud/maven-repo' }
    google()
    mavenCentral()
}

Next, add the libraries to your app module. For the playback engine, add shoplive-exoplayer and shoplive-media3 — choose only one of them. Adding both duplicates classes and breaks the build.

Declare all four shoplive-sdk-core does not ship shoplive-common · shoplive-network · or the playback engine as transitive dependencies, so you must declare them all. If any is missing, the build may pass but classes go missing at runtime.
// app/build.gradle
dependencies {
    def shoplive_sdk_version = "1.9.0"

    implementation "cloud.shoplive:shoplive-sdk-core:$shoplive_sdk_version"
    implementation "cloud.shoplive:shoplive-common:$shoplive_sdk_version"
    implementation "cloud.shoplive:shoplive-network:$shoplive_sdk_version"
    implementation "cloud.shoplive:shoplive-exoplayer:2.19.1.20"
    // To use Media3 instead of ExoPlayer:
    // implementation "cloud.shoplive:shoplive-media3:1.10.1.3"
}
Android cloud.shoplive:shoplive-sdk needs to be replaced if you still use it The name shoplive-sdk that appears in older docs no longer exists in the repository (404). Leave it in and Gradle cannot resolve the dependency, so the build fails. As in the example above, use shoplive-sdk-core · shoplive-common · shoplive-network, plus shoplive-exoplayer or shoplive-media3 as the playback engine.

Finally, register the event handler. Everything a viewer taps inside the player (products, coupons, the share button and so on) reaches your app through this handler.

ShopLive.setHandler(object : ShopLiveHandler() {
    override fun handleNavigation(context: Context, url: String) {
        // Handle link navigation
    }
})
Android buttons do nothing unless you register the handler The player still opens, but tapping a product goes nowhere and your app never hears about a downloaded coupon. The full list of events you can receive is in Event handlers.
Nothing to do for ProGuard or R8 Each library ships its own obfuscation rules, so simply adding the dependency applies them. If you want to check, the Platform appendix lists which module carries which rules.

Flutter Install

flutter pub add 'shoplive_player:^1.9.0'
# pubspec.yaml
dependencies:
  shoplive_player: ^1.9.0
Always pin the version Without a version, the V3 (3.x) package is installed. 3.x has no ShopLivePlayer · ShopLiveCommon, so the examples in this document do not compile.

iOS — the plugin depends on the ShopLive 1.9.0 pod. That pod exists only in the Shoplive spec repo (shoplive/pod-specs); the latest on the official CocoaPods trunk is 1.8.13. So add the three lines below at the top of ios/Podfile and run pod install --repo-update.

# ios/Podfile (top)
source 'https://github.com/shoplive/pod-specs.git'
source 'https://cdn.cocoapods.org/'
platform :ios, '15.0'
cd ios && pod install --repo-update

Android — the plugin adds the Shoplive maven repository, but if settings.gradle is in FAIL_ON_PROJECT_REPOS mode, that setting is ignored. We recommend adding it to your app's repository list as well.

// android/build.gradle(.kts)
allprojects {
    repositories {
        google()
        mavenCentral()
        maven { url = uri("https://sdk.shoplive.cloud/maven-repo") }
    }
}

After installing, add the import statement to the Dart file you'll use.

import 'package:shoplive_player/shoplive_player.dart';
import 'package:shoplive_player/shoplive_common.dart';

Verify completion

  • iOS CocoaPods builds through .xcworkspace, and SPM builds normally through package resolution
  • iOS SPM In Xcode's Package Dependencies list, the resolved version is 1.9.0 (not 3.x)
  • The Shoplive maven repository is added to the Android project and ShopLiveHandler is registered
  • Flutter pubspec.yaml's dependencies has shoplive_player: ^1.9.0 added (not 3.x)

Previous ← Overview & RequirementsNext → Quickstart

Getting started

Quickstart

GoalGet a broadcast on screen with just 2 keys
PrerequisitesIssue an Access Key · Campaign Key from Shoplive Admin
import ShopliveSDKCommon
import ShopLiveSDK

// 1) Once, at app launch
ShopLiveCommon.setAccessKey(accessKey: "YOUR_ACCESS_KEY")

// 2) Enter viewing
ShopLive.play(data: .init(campaignKey: "YOUR_CAMPAIGN_KEY"))
// 1) Once, at app launch
ShopLive.setAccessKey("YOUR_ACCESS_KEY")
ShopLive.setHandler(myHandler)

// 2) Enter viewing
ShopLive.play(this, ShopLivePlayerData("YOUR_CAMPAIGN_KEY"))
final common = ShopLiveCommon();
final player = ShopLivePlayer();

// 1) Once, at app launch
common.setAccessKey(accessKey: 'YOUR_ACCESS_KEY');

// 2) Enter viewing
player.play(data: ShopLivePlayerData(campaignKey: 'YOUR_CAMPAIGN_KEY'));

Previous ← InstallNext → Screen terminology guide

Screens & demo

Screen terminology guide

The player fills the whole screen with video and layers elements such as badges, buttons and chat on top. The Shoplive logo in the top left is always visible. This guide uses the same name for each element throughout, so agreeing on the names here makes the later missions much easier to follow.

Below are the before the broadcast with During broadcast screens of the same campaign. The numbers are on the right-hand (live) screen, where every element is visible, and the table right below maps each number to the mission that covers it. How to open the player itself is in Mission 1.

Player screen before the broadcast (UPCOMING) and during it (LIVE), side by side 1 2 3 4 5 6 7 8
Left · before the broadcastUPCOMING Right · liveLIVE
the LIVE badge, viewer count, coupon bubble, and chat overlay only appear once the broadcast starts, which is why the numbers are on the right-hand screen only.
No.ElementWhere it's covered
1LIVE badgeThe SDK shows this automatically based on broadcast status (no integration needed)
2Viewer count · like countThe SDK updates these automatically (no integration needed). The brand like (follow) button in Mission 11 is a different element. This number is display-only, while that button needs its own campaign setting.
3Mute buttonMission 11
4More · Share · Close(top-right icon column)Sharing is covered in Mission 7, and dropping into a small window when the user navigates away is covered in Mission 8
5Coupon bubbleMission 5
6Chat text overlayMission 4
7Open chat · like button(bottom-right icon column)Opening chat is covered in Mission 4. Whether the like button is the same feature as the is the same feature as the brand like (follow) in Mission 11 is not confirmed yet (Needs verification)
8Product cardMission 6
Some elements appear in the image but not in the table The image above comes from the V3 guide. V1 and V3 share the webview that renders the player, so the layout is identical. That said, the campaign title·notice pill·event popup are not confirmed to always appear in V1, so they are left out of the table (Needs verification). They may depend on campaign settings, so check them against a real campaign screen when you start integrating, or ask your Shoplive contact.

Previous ← QuickstartNext → Check with the demo app

Screens & demo

Check with the demo app

The Shoplive org has several demo repositories, but most of them are for V3. Opening a V3 demo while integrating V1 is confusing because the APIs are completely different. Use the table below to tell them apart first.

RepositoryPlatformSDKUsable as-is?
shoplive-sdk-flutter FlutterV1(1.9.0 tag)✅ Run after switching to the tag (below)
ios-sdk-sample iOSV1⚠️ Builds only after fixing the Podfile (below)
mobile-sdk-demo iOS · AndroidV3❌ Different API from V1, not even usable as a reference
android-sdk-sampleAndroid— ❌ Not a demo — an AAR/POM distribution repository
There is no public V1 Android demo app As the table shows, we couldn't find a public repository with V1 examples for Android. Use the examples in this guide for Android, and if you need a runnable demo, ask your Shoplive contact when you start integrating. Whether an internal build exists is Needs verification.

Flutter clone and run

shoplive_player package's (published on pub.dev) example app is in shoplive_flutter_sample/example/.

git clone https://github.com/shoplive/shoplive-sdk-flutter.git
cd shoplive-sdk-flutter && git checkout 1.9.0
cd shoplive_flutter_sample/example && flutter pub get && flutter run
Switch to the 1.9.0 tag The default branch is V3, so unless you switch to the 1.9.0 tag you get the V3 example.

iOS fix the Podfile first

The Xcode project (swift/ShopLiveSwiftSample) has several menus, including PIP settings, option settings, and user info. The Podfile committed to the repository, however, cannot be used as-is.

The committed Podfile won't build The Shoplive pod lines are all commented out (and even the commented version is 1.8.2), and it sets platform :ios, '11.0' while the Shoplive spec repository source is missing as well. Install's Podfile example (including both source lines) instead, pin the version to 1.9.0, and run pod install --repo-update.

Previous ← Screen terminology guideNext → Mission 1 · Launch the player

Missions

Mission 1 · Launch the player

GoalInitialize with the Access Key and play the broadcast with the Campaign Key
PrerequisitesInstall done · Access Key · Campaign Key (how to get one)
  1. Initialize the SDK once, at app startup, with the Access Key.
  2. Call playback with the Campaign Key when the user starts watching.
  3. (Optional) You can draw viewers in with a low-resolution, muted preview.
import ShopliveSDKCommon
import ShopLiveSDK

ShopLiveCommon.setAccessKey(accessKey: "{ACCESS_KEY}")
ShopLive.play(data: .init(campaignKey: "{CAMPAIGN_KEY}"))
ShopLive.setAccessKey(accessKey)
ShopLive.play(this, ShopLivePlayerData(campaignKey).apply {
    keepWindowStateOnPlayExecuted = false
    referrer = "referrer"
})
final common = ShopLiveCommon();
final player = ShopLivePlayer();

common.setAccessKey(accessKey: "{ACCESS_KEY}");
player.play(data: ShopLivePlayerData(campaignKey: "{CAMPAIGN_KEY}"));
iOS Using it from Objective-C ShopLivePlayerData has an Objective-C version: ShopLivePlayerDataObjc. The constructor is initWithCampaignKey:keepWindowStateonPlayExecuted:referrer:. The on in the label is lowercase. Keep it as is.

Drawing viewers in with a Preview

You can first show an ongoing broadcast as a low-resolution, muted preview, and encourage the user to tap it to switch to full playback. All three platforms provide it, but the call style differs. iOS takes a data object, while on Flutter setting the options and showing the preview are split into two calls.

let previewData = ShopLivePreviewData(
    campaignKey: "{CAMPAIGN_KEY}",
    isMuted: true,
    resolution: .PREVIEW
)

ShopLive.preview(data: previewData) {
    // Call play here if you want the tap on the preview to switch to full playback
    ShopLive.play(data: .init(campaignKey: "{CAMPAIGN_KEY}"))
}
ShopLive.showPreviewPopup(this@YourActivity, ShopLivePreviewData(campaignKey))
// 1) Set the options first
player.setPreviewOption(
  data: ShopLivePlayerPreviewData(
    campaignKey: "{CAMPAIGN_KEY}",
    useCloseButton: true,
    enableSwipeOut: true,
    position: "BOTTOM_RIGHT",
  ),
);

// 2) Then show it (use hidePreview to hide)
player.showPreview(campaignKey: "{CAMPAIGN_KEY}");
Platform differences iOS ShopLive.preview(data:completion:) completion fires when the preview is tapped. Whether to switch to full playback is up to your app. The older preview(with campaignKey:) is deprecated. On Flutter, setPreviewOption (options) and showPreview (show) are separate, so you must set the options first; to close it, use hidePreview(). On Android, API · Options's setPreviewOptions sets the preview window shape separately.

Verify completion

  • Access Key initialization has run exactly once, right after app launch
  • Tapping the button → the broadcast plays within 2–3 seconds
  • On playback failure, the error callback (iOS handleError / Android onError) logs a code and message

Previous ← Check with the demo appNext → Mission 2 · Launch via deep link

Missions

Mission 2 · Launch via deep link

GoalPass ad parameters received via deep link to the SDK, and take the user directly into that link's broadcast
PrerequisitesMission 1 done

Links in ads, push notifications and text messages usually carry both which broadcast to open and where the user came from as query parameters. When your app opens from such a link, pull the queries out one by one, hand them to the SDK, then start the player.

This mission assumes a link shaped like https://xxxx/yyyy?ak=…&ck=…&utm_source=…. Each kind of query is passed differently, so check the table below first to see what goes where.

QueryValueHow to pass to the SDK
akAccess KeysetAccessKey
ckCampaign KeyUsed as campaignKey when launching the player
utm_source · utm_medium
utm_campaign · utm_content
Ad inflow channelsetUtmSource, and other dedicated setters
Any other queryA value your app definesaddParameter(key, value)

Android Manifest setup

First declare the Activity that will receive the deep link in the Manifest. intent-filter's data This Activity opens when a link arrives at the address written in the data element.

<!-- AndroidManifest.xml -->
<activity
    android:name=".SchemeActivity"
    android:exported="true"
    android:noHistory="true"
    android:screenOrientation="portrait">
    <intent-filter android:autoVerify="true">
        <action android:name="android.intent.action.VIEW" />

        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />

        <data
            android:scheme="https"
            android:host="xxxx"
            android:path="/yyyy" />
    </intent-filter>
</activity>
iOS and Flutter skip this step YOU GET the link is plain platform setup that has nothing to do with the SDK. On iOS configure URL Types (custom scheme) or Associated Domains (Universal Links) in Xcode; on Flutter use a plugin such as app_links·uni_links or native configuration. The SDK only gets involved from Passing query parameters onwards, and from there all three platforms are the same.

https:// links, want to receive shoplive://live?ak=…&ck=… on your own scheme instead, add one more <intent-filter> like the one below. Leave android:autoVerify out: it is an attribute specific to https links for verifying domain ownership, and it does nothing for a custom scheme.

<intent-filter>
    <action android:name="android.intent.action.VIEW" />

    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />

    <data
        android:scheme="shoplive"
        android:host="live" />
</intent-filter>

Passing query parameters

From here all three platforms are the same. Pull the queries out of the URL one at a time and pass each to the right place. ak goes to the Access Key, the four that start with utm each go to their own setter, and everything else goes to addParameter. Once everything is passed, start the player with the ck value.

// SceneDelegate
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
    guard let url = URLContexts.first?.url,
          let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
          let queryItems = components.queryItems else { return }

    var campaignKey: String? = nil

    for item in queryItems {
        let value = item.value ?? ""
        switch item.name {
        case "ak":           ShopLiveCommon.setAccessKey(accessKey: value)
        case "ck":           campaignKey = value
        case "utm_source":   ShopLiveCommon.setUtmSource(utmSource: value)
        case "utm_medium":   ShopLiveCommon.setUtmMedium(utmMedium: value)
        case "utm_campaign": ShopLiveCommon.setUtmCampaign(utmCampaign: value)
        case "utm_content":  ShopLiveCommon.setUtmContent(utmContent: value)
        default:             ShopLive.addParameter(key: item.name, value: value)
        }
    }

    guard let campaignKey = campaignKey else { return }
    ShopLive.play(data: .init(campaignKey: campaignKey))
}
class SchemeActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        handle(intent)
    }

    override fun onNewIntent(intent: Intent?) {
        super.onNewIntent(intent)
        handle(intent)
    }

    private fun handle(intent: Intent?) {
        val uri = intent?.data
        if (uri == null) {
            finish()
            return
        }

        var campaignKey: String? = null

        for (name in uri.queryParameterNames) {
            val value = uri.getQueryParameter(name) ?: continue
            when (name) {
                "ak" -> ShopLiveCommon.setAccessKey(value)
                "ck" -> campaignKey = value
                "utm_source" -> ShopLive.setUtmSource(value)
                "utm_medium" -> ShopLive.setUtmMedium(value)
                "utm_campaign" -> ShopLive.setUtmCampaign(value)
                "utm_content" -> ShopLive.setUtmContent(value)
                else -> ShopLive.addParameter(name, value)
            }
        }

        // Leave player launch to the Activity started with NEW_TASK (see the note below)
        startActivity(Intent(this, MainActivity::class.java).apply {
            flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
            putExtra("campaignKey", campaignKey)
        })
        finish()
    }
}
final common = ShopLiveCommon();
final player = ShopLivePlayer();

void handleDeepLink(Uri uri) {
  uri.queryParameters.forEach((key, value) {
    switch (key) {
      case 'ak':
        common.setAccessKey(accessKey: value);
        break;
      case 'ck':
        break; // Used right below
      case 'utm_source':
        common.setUtmSource(utmSource: value);
        break;
      case 'utm_medium':
        common.setUtmMedium(utmMedium: value);
        break;
      case 'utm_campaign':
        common.setUtmCampaign(utmCampaign: value);
        break;
      case 'utm_content':
        common.setUtmContent(utmContent: value);
        break;
      default:
        player.addParameter(key: key, value: value);
    }
  });

  final campaignKey = uri.queryParameters['ck'];
  if (campaignKey != null) {
    player.play(data: ShopLivePlayerData(campaignKey: campaignKey));
  }
}
iOS there is no zero-argument ShopLive.play() Older docs tell you to set the parameters and finish with ShopLive.play(), but written that way it does not compile. The only call available today is play(data:). Pass the campaignKey you pulled from the link into play(data:) as shown above.
Passing utm values through addParameter keeps them out of your analytics setUtmSource Only values passed through the dedicated setters such as this one are counted as acquisition sources in the Shoplive admin analytics. addParameter just attaches them as one more custom query. Always use the dedicated setters for the four utm values.
Android the PIP transition can misbehave after a deep link That is why the Android example above puts the entry point in a separate SchemeActivity and launches the next screen with FLAG_ACTIVITY_NEW_TASK. The reason and the symptoms are written up in FAQ · Troubleshooting.

Verify completion

  • Opening the app via a deep link immediately plays the link's ck broadcast
  • Entering via a link with utm parameters records the inflow channel in Shoplive Admin analytics
  • (Android) After entering via deep link, tapping a product to drop into PIP still brings the app screen back to the front correctly

Previous ← Mission 1Next → Mission 3 · Connect user information

Missions

Mission 3 · Connect user information

GoalMake chat and click logs accumulate against your own service's user identities
PrerequisitesMission 1 done · (for secure auth) backend JWT issuance ready (Create the JWT for secure auth)

Telling the player who the viewer is makes their name show up in chat and files product-click and purchase logs under that person. Without it the broadcast still plays, but you cannot tell viewers apart.

This passes user information — it does not authenticate the user Sign-in (ID · password, social login and so on) stays entirely in your app. setUser·setAuthToken only tell Shoplive who the current viewer is so that chat, viewing and statistics are attributed to that member. Shoplive does not check the user's credentials. With secure auth (the JWT you pass via setAuthToken), Shoplive can verify that the information was issued by your server and not altered (signature check); with simple auth (setUser), the information is passed as is, without that check.

Choose by sign-in state — recommended setup

UserRecommendedReason
Signed out Guest No member data needs to be passed. If you want a display name, you can set one separately
Signed in (recommended) Secure auth — a JWT signed by your backend It can't be forged, so you can use it for grade-based entry limits, purchase integration, and accurate viewer stats
Signed in (when backend work is hard) Simple auth — pass ID, name and so on directly from the app It's unsigned, so the values can be swapped out in the app. It's enough for identification and stats, but don't use it for authorization
Log out · reset user info Reset the user info, then relaunch the player Clear the signed-out member's user info (JWT and profile) so none of it stays. Changes apply from the next playback, so relaunch the player
Recommended flow App launch (signed out) → guest → signed in: right before each playback, get a newly issued JWT from your backend and call setAuthToken → on sign-out, reset the user info. User changes apply from the next playback. If the player is already playing, relaunch it as in Switching from guest to a signed-in account.
iOS isLoggedIn() — do not use it to check login iOS ShopLiveCommon.isLoggedIn() always returns true in 1.9.0, logged in or not. Decide login from your app's own member state.

Using a guest account

import ShopLiveSDK
import ShopliveSDKCommon

let user = ShopLiveCommonUser(userId: "{USER_ID}", userName: "{USER_NAME}")
ShopLiveCommon.setUser(user: user)

ShopLive.play(data: .init(campaignKey: "{CAMPAIGN_KEY}"))
val user = ShopLiveCommonUser(userId = "{USER_ID}").apply {
    userName = "{USER_NAME}"
}
ShopLive.setUser(user)

ShopLive.play(this, ShopLivePlayerData(campaignKey))
// common and player reuse the instances created in Mission 1
final user = ShopLiveCommonUser(userId: "{USER_ID}", userName: "{USER_NAME}");
common.setUser(accessKey: "{ACCESS_KEY}", user: user);

player.play(data: ShopLivePlayerData(campaignKey: "{CAMPAIGN_KEY}"));
Guest chat If guest chat isn't allowed in Admin, tapping the chat button does nothing. If a guest display name is required, a name-entry popup appears first when the user tries to chat.

Connecting a simple-auth user account

Values such as ID and name are passed straight from the app. No server work, but no protection against forgery. userId can take a hashed value instead of the raw member ID — see ID hashing.

let user = ShopLiveCommonUser(
    userId: "{USER_ID}",
    userName: "{USER_NAME}",
    age: 25,
    gender: .male,
    userScore: 80,
    custom: ["grade": "vip"])

ShopLiveCommon.setUser(user: user)
ShopLive.play(data: .init(campaignKey: "{CAMPAIGN_KEY}"))
val user = ShopLiveCommonUser(userId = "{USER_ID}").apply {
    userName = "{USER_NAME}"
    gender = ShopLiveCommonUserGender.MALE
    age = 25
    userScore = 20
}
ShopLive.setUser(user)
final user = ShopLiveCommonUser(
  userId: "{USER_ID}",
  userName: "{USER_NAME}",
  gender: ShopLiveCommonUserGender.MALE,
  userScore: 100,
  age: 25,
  custom: {"key": "value"},
);
common.setUser(accessKey: "{ACCESS_KEY}", user: user);
iOS Using it from Objective-C ShopLiveCommonUser has an Objective-C version: ShopLiveCommonUserObjc. The constructor is initWithUserId:userName:age:gender:userScore:custom:; age · userScore are NSNumber, gender is a string (m · f · n).

ID hashing — don't expose raw member IDs

When you'd rather not pass your internal member ID as is, use a value that your app and backend hash the same way as the userId. The key is using a consistent salt and algorithm so the same user always produces the same value (for statistics and return-visitor identification).

View hashing code samples (Swift · Kotlin · Dart)
import CryptoKit

func hashUserId(_ raw: String) -> String {
    let salted = raw + "APP_SALT"
    let digest = SHA256.hash(data: Data(salted.utf8))
    return digest.map { String(format: "%02x", $0) }.joined()
}
import java.security.MessageDigest

fun hashUserId(raw: String): String {
    val salted = raw + "APP_SALT"
    return MessageDigest.getInstance("SHA-256")
        .digest(salted.toByteArray())
        .joinToString("") { "%02x".format(it) }
}
// pubspec.yaml — dependencies: crypto
import 'dart:convert';
import 'package:crypto/crypto.dart';

String hashUserId(String raw) {
  final salted = raw + "APP_SALT";
  return sha256.convert(utf8.encode(salted)).toString();
}
Security note Hashing only obscures the raw value — it is not authentication. If you need to verify viewing permissions, always use a server-signed JWT (secure auth). We recommend agreeing on the salt policy with the Shoplive team.

Create the JWT for secure auth (your backend)

For secure auth, use the Secret Key issued by your Shoplive contact: your backend signs the JWT with it. The app only fetches the JWT from your backend after sign-in and passes it to setAuthToken. The app-side code is in Connecting a secure-auth user account.

  1. Get the Secret Key from your Shoplive contact. It's a base64-encoded string
  2. From the signed-in user's info, build the payload below and sign it using HS256 as the algorithm
  3. Issue a new JWT every time the app asks (right before each playback) and return it. Don't store a JWT in the app and reuse it
Generate the JWT fresh each time Don't cache a JWT on the device or reuse an old one. User details (name, grade, type) may have changed since the last issue, and a token that leaks stays usable until its exp. Have the app request a new JWT from your backend whenever it calls setAuthToken.
claimRequiredDescriptionExample
expRequiredExpiration time (Unix seconds)1516239022
userIdRequiredUnique value for the signed-in user (up to 255 bytes). A hashed value works too (ID hashing)sample_user_id
iatIssued-at time (Unix seconds)1516195822
nameName shown in chat. Banned words are masked with asterisks (*){USER_NAME}
genderGender — m · f · empty (unspecified)f
ageAge. Stats group it in 5-year bands27
userScoreUser grade (-100 to 100; a server rule, not checked by the SDK). The basis for grade-based entry and prize-draw limits10
userTypeUser type. Lets only certain types into broadcasts with an entry restriction (Entry Type)VIP
profileChat profile image URL (64×64px recommended)https://…/profile.png
customArbitrary data (up to 1 KB; a server rule, not checked by the SDK){"grade":"vip"}

Signing examples. Both produce the same token — just note that the Secret Key is a base64 string.

// Node.js — npm i jsonwebtoken
const jwt = require("jsonwebtoken");

const secret = Buffer.from(process.env.SHOPLIVE_SECRET_KEY, "base64");  // base64-decode it into bytes
const token = jwt.sign(
  { userId: hashedUserId, name: "{USER_NAME}", gender: "f", age: 27, userScore: 10 },
  secret,
  { algorithm: "HS256", expiresIn: "12h" }   // fills in exp and iat automatically
);
// Java — io.jsonwebtoken (jjwt)
String jwt = Jwts.builder()
    .signWith(SignatureAlgorithm.HS256, base64EncodedSecretKey)   // pass the base64 string as is
    .setExpiration(new Date(System.currentTimeMillis() + 12 * 60 * 60 * 1000L))
    .setIssuedAt(new Date())
    .claim("userId", hashedUserId)
    .claim("name", "{USER_NAME}")
    .claim("gender", "f")
    .claim("age", 27)
    .claim("userScore", 10)
    .compact();
Keep the Secret Key on your backend If it ships in app code or the bundle, anyone can extract it and forge tokens for other users. Simple auth is unsigned, so you can't use it for authorization.

Connecting a secure-auth user account

Take the JWT your backend signed above and pass it to the SDK right before playback.

ShopLiveCommon.setAuthToken(authToken: "{USER_JWT}")
ShopLive.play(data: .init(campaignKey: "{CAMPAIGN_KEY}"))
ShopLiveCommon.setAuthToken("{USER_JWT}")
ShopLive.play(this, ShopLivePlayerData(campaignKey))
common.setAuthToken(userJWT: "{USER_JWT}");
player.play(data: ShopLivePlayerData(campaignKey: "{CAMPAIGN_KEY}"));
iOS setUserJWT is not a real API An SDK code comment sometimes points to ShopLiveCommon.setUserJWT, but no such API actually exists. Be sure to use ShopLiveCommon.setAuthToken(authToken:).
Android getAuthToken() comes in two flavours ShopLive.getAuthToken() only returns the value set via setAuthToken(). To read a JWT issued via setUser(), use ShopLiveCommon.getAuthToken().
Flutter setUser requires accessKey ShopLiveCommon().setUser(accessKey:user:) requires both arguments — accessKey can't be omitted.

Detecting username changes

When a user changes their display name directly inside Shoplive Player, the SDK notifies your app of the change.

func onSetUserName(_ payload: [String: Any]) {
    payload.forEach { key, value in
        print("onSetUserName key: \(key) value: \(value)")
    }
}
override fun onSetUserName(jsonObject: JSONObject) {
    super.onSetUserName(jsonObject)
    Log.d(TAG, "userId=${jsonObject.get("userId")}, userName=${jsonObject.get("userName")}")
}
player.userInfo.listen((data) {
  print("userInfo: ${data.userInfo}");
});
The original guide does not list this event for Flutter, but the SDK has it. userInfo stream delivers the same native event as iOS/Android's onSetUserName.

Switching from guest to a signed-in account

If guest chat has been blocked with OFF in Admin and the user tries to chat, a LOGIN_REQUIRED command is delivered. Send them to your login screen, then on a successful login, set the user info again via simple/secure auth above and relaunch the player.

func handleReceivedCommand(_ command: String, data: [String: Any]?) {
    switch command {
    case "LOGIN_REQUIRED":
        // Navigate to the login screen → on success, reset the user info and relaunch the player
        break
    default:
        break
    }
}
override fun onReceivedCommand(context: Context, command: String, data: JSONObject) {
    when (command) {
        "LOGIN_REQUIRED" -> {
            // Navigate to the login screen → on success, reset the user info and relaunch the player
        }
    }
}
player.receivedCommand.listen((data) {
  switch (data.command) {
    case "LOGIN_REQUIRED":
      // Navigate to the login screen → on success, reset the user info and relaunch the player
      break;
    default:
      break;
  }
});

Log out — reset the user info

When the user logs out, clear the user info you set (JWT and profile). The API is named setAuthToken, but the value you pass is user info (a JWT). Changes apply from the next playback, so relaunch the player if it was playing.

// Log out — reset the user info (clears the JWT and profile)
ShopLiveCommon.setAuthToken(authToken: nil)
ShopLiveCommon.setUser(user: nil)

// Clear at once — clears the JWT · user · accessKey
ShopLiveCommon.clearAuth()
ShopLiveCommon.setAccessKey(accessKey: "{ACCESS_KEY}")   // set it again before the next playback
// Log out — reset the user info (clears the JWT and profile)
ShopLiveCommon.setAuthToken(null)
ShopLiveCommon.setUser(null)
// ShopLive.resetUser() is the same as setUser(null)

// Clear at once — clears only the user · JWT (accessKey is kept)
ShopLiveCommon.clearAuth()
// Log out — reset the user info
common.setAuthToken(userJWT: null);
common.clearAuth();   // Flutter's setUser does not accept null
clearAuth() clears different things on each platform
  • iOS ShopLiveCommon.clearAuth() clears the JWT · user · accessKey — all of them. Call setAccessKey again before the next playback.
  • Android ShopLiveCommon.clearAuth() clears only the user · JWT and keeps the accessKey (unlike iOS). ShopLiveCommon.isLoggedIn() returns true when a JWT exists.
Flutter clearAuth() also clears the accessKey on iOS Flutter's clearAuth() calls the native ShopLiveCommon.clearAuth() as is. On iOS that also resets the accessKey, so on the next login pass the accessKey again with setUser(accessKey:user:). Flutter's setUser requires accessKey as an argument anyway.

Verify completion

  • After signing in, the chat nickname shows the member's name
  • The user ID (hash) can be looked up in Shoplive Admin statistics
  • As a guest, the chat display name shows correctly, and after sign-out, resetting the user info and relaunching the player makes viewing continue as a guest
  • Each playback passes a JWT newly issued by your backend, and the Secret Key is not in the app code or bundle
  • On Android, you didn't confuse ShopLiveCommon.getAuthToken() with ShopLive.getAuthToken()

Previous ← Mission 2Next → Mission 4 · Chat

Missions

Mission 4 · Chat

GoalCheck chat permission, and if needed, match the chat font to your service's tone
PrerequisitesMission 3 done
  1. In the Shoplive admin, go to Broadcast details → Chat permissions and check whether guest chat is allowed.
  2. If guest chat is blocked, Mission 3's login-prompt flow applies as-is.
  3. (Optional) Change the chat/send-button font to match your app's font.
let inputFont = UIFont.systemFont(ofSize: 14, weight: .regular)
let sendButtonFont = UIFont.systemFont(ofSize: 14, weight: .medium)

ShopLive.setChatViewFont(inputBoxFont: inputFont, sendButtonFont: sendButtonFont)
val nanumGothic = ResourcesCompat.getFont(requireContext(), R.font.nanumgothic)
ShopLiveCommon.setTextTypeface(nanumGothic)
Flutter does not expose an API for changing the chat font. The chat UI is rendered in the native layer (iOS/Android), so changing the font requires configuration in each native project.
You can manage chat directly from Shoplive Admin while live streaming is in progress (delete messages, restrict users, etc.).

Verify completion

  • Admin's chat permission setting matches the app's actual behavior (whether guests are allowed)
  • (If you changed the font) the specified font is applied to the chat input field and send button

Previous ← Mission 3Next → Mission 5 · Coupons

Missions

Mission 5 · Coupons

GoalImplement callbacks for the coupon bubble's 3 click event types (LINK · Download coupon · User define)
PrerequisitesInstall's Handler registration (Android) · Mission 3 done

This is the case where the coupon click event is set to LINK or LINK (New window).

func handleNavigation(with url: URL) {
    let safari = SFSafariViewController(url: url)
    present(safari, animated: true)
}
override fun handleNavigation(context: Context, url: String) {
    val intent = Intent(this@YourActivity, WebViewActivity::class.java)
    intent.putExtra("url", url)
    startActivity(intent)
}
player.handleNavigation.listen((data) {
  launchUrl(Uri.parse(data.url), mode: LaunchMode.externalApplication);
});

Download coupon

This is the case where the coupon click event is set to Download coupon. You receive the coupon ID, process the issuance, and return the result to the SDK.

func handleDownloadCoupon(with couponId: String, result: @escaping (ShopLiveCouponResult) -> Void) {
    let couponResult = ShopLiveCouponResult(
        couponId: couponId, success: true,
        message: "Coupon download success!",
        status: .SHOW, alertType: .ALERT)
    result(couponResult)
}
override fun handleDownloadCoupon(
    context: Context,
    couponId: String,
    callback: ShopLiveHandlerCallback
) {
    callback.couponResult(
        isDownloadSuccess = true,
        message = "Coupon download success!",
        couponStatus = ShopLive.CouponPopupStatus.HIDE,
        alertType = ShopLive.CouponPopupResultAlertType.ALERT
    )
}
player.handleDownloadCoupon.listen((data) async {
  await player.sendDownloadCouponResult(
    couponId: data.couponId,
    success: true,
    message: "Coupon download success!",
    popupStatus: "KEEP",
    alertType: "ALERT",
  );
});
callback is an object the SDK creates and hands to you. callback.couponResult(...), and you don't need to build a new ShopLiveHandlerCallback yourself.

User define

This is the case where the coupon click event is set to User define. You receive your own defined payload as-is.

func handleCustomAction(with id: String, type: String, payload: Any?, result: @escaping (ShopLiveCustomActionResult) -> Void) {
    let actionResult = ShopLiveCustomActionResult(
        id: id, success: true, message: "success!",
        status: .SHOW, alertType: .ALERT)
    result(actionResult)
}
override fun handleCustomAction(
    context: Context,
    id: String,
    type: String,
    payload: JSONObject,
    callback: ShopLiveHandlerCallback
) {
    callback.customActionResult(
        isSuccess = true,
        message = "success!",
        couponStatus = ShopLive.CouponPopupStatus.HIDE,
        alertType = ShopLive.CouponPopupResultAlertType.ALERT
    )
}
player.handleCustomAction.listen((data) async {
  await player.sendCustomActionResult(
    id: data.id,
    success: true,
    message: "success!",
    popupStatus: "SHOW",
    alertType: "ALERT",
  );
});
Android the parameter is named couponStatus customActionResult's 3rd argument is named couponStatus. Even though this is a custom action, the name matches the coupon family. The SDK source defines it that way, so use it as is. The equivalent argument on Flutter is named popupStatus instead — watch for this when copying code across platforms.
User define coupon settings
Setting a payload on a User define coupon

Verify completion

  • Clicking a LINK coupon navigates using the configured method (Safari · web view · external browser)
  • Clicking Download coupon shows the issuance result popup (SHOW/HIDE/KEEP) as intended
  • Clicking User define logs the payload's id/type correctly

Previous ← Mission 4Next → Mission 6 · Product clicks

Missions

Mission 6 · Product clicks

GoalHave the app receive product/cart clicks from the broadcast screen and route them to your product detail/cart screens
PrerequisitesMission 5 done · Install's Handler registration (Android)
  1. When a viewer taps a product or the cart button in the product list, the SDK delivers a CLICK_PRODUCT_DETAIL·CLICK_PRODUCT_CART command to your app.
  2. If a product or banner has a detail URL set, the same click is also delivered as handleNavigation.
  3. On iOS, hookNavigation is also available, which intercepts only link navigation through a single closure.

Product/cart click commands

The command names are the same across all three platforms. Since the command-receiving callback also carries other commands like coupons and login requests, branch on the command name to handle it.

// ShopLiveSDKDelegate
func handleReceivedCommand(_ command: String, data: [String: Any]?) {
    switch command {
    case "CLICK_PRODUCT_DETAIL":
        // data: info about the selected product
        break
    case "CLICK_PRODUCT_CART":
        // data: info about the product added to the cart
        break
    default:
        break
    }
}
// ShopLiveHandler
override fun onReceivedCommand(context: Context, command: String, data: JSONObject) {
    when (command) {
        "CLICK_PRODUCT_DETAIL" -> {
            // data: info about the selected product
        }
        "CLICK_PRODUCT_CART" -> {
            // data: info about the product added to the cart
        }
    }
}
final player = ShopLivePlayer();

player.receivedCommand.listen((event) {
  switch (event.command) {
    case 'CLICK_PRODUCT_DETAIL':
      // event.data: info about the selected product
      break;
    case 'CLICK_PRODUCT_CART':
      // event.data: info about the product added to the cart
      break;
  }
});
Check the payload fields with a log first The product info riding on the command is passed through as-is from what's registered on the campaign, so the field composition varies by service. Don't assume a fixed schema — log the actual payload once before you parse it.
iOS handleReceivedCommand(_:with:) is deprecated The version whose argument is with payload: Any? is the old one; the current one is data: [String: Any]? from the example above. Both are @objc optional, so implementing the wrong one still builds — if the value doesn't come through, check this signature first.

Handling link navigation (handleNavigation)

If a product or banner has a detail URL registered, that tap also arrives at handleNavigation. Mission 5's LINK coupon. It is the same callback, so coupon, product and banner taps all land here. You decide where to send the user from the URL alone, so agreeing on a link format up front makes branching easier.

func handleNavigation(with url: URL) {
    // url: the detail URL set on the selected product or banner
    print("handleNavigation \(url)")
}
override fun handleNavigation(context: Context, url: String) {
    Log.d(TAG, "handleNavigation url=$url")
}
final player = ShopLivePlayer();

player.handleNavigation.listen((event) {
  debugPrint("handleNavigation : ${event.url}");
});
One product tap fires two callbacks

Tapping a product that has a detail URL registered calls CLICK_PRODUCT_DETAIL and handleNavigation can return both. The web player sends one tap as two separate messages. (A product with no detail URL only sends the command, without handleNavigation.)

So if you implement navigation on both sides, the detail screen opens twice. Handle navigation on one side only. If every product has a detail URL, put it all in handleNavigation; if some products have no URL, it is simpler to handle everything in the command callback.

iOS only hookNavigation

Use this when you want to receive only link navigation as a closure, bypassing the delegate.

ShopLive.hookNavigation { url in
    // url: the detail URL set on the selected product or banner
    print("hookNavigation \(url)")
}
Registering hookNavigation means handleNavigation won't be called When a hook is registered the SDK calls only that closure and returns immediately. Not just the handleNavigation delegate, but also the behaviour that drops the player into PIP or closes it after a link tap (setNextActionOnHandleNavigation) is skipped. Use one or the other, not both.

Verify completion

  • Tapping a product in the product list logs CLICK_PRODUCT_DETAIL
  • Tapping the cart button logs CLICK_PRODUCT_CART
  • Tapping a product with a detail URL set delivers the URL via handleNavigation
  • (iOS, when using hookNavigation) only the hook closure is called, instead of handleNavigation

Previous ← Mission 5Next → Mission 7 · Share the broadcast link

Missions

Mission 7 · Share the broadcast link

GoalExport the currently-watched broadcast link to the system share sheet or your app's own share UI
PrerequisitesMission 1 done
  1. Set the link format the share button exports (URI scheme or web URL) via setShareScheme.
  2. If you don't handle it separately, the SDK shows the OS default share sheet.
  3. To use a share UI that matches your app design, receive the share data and present your own screen.

Specifying the share link format

If you include ak(Access Key)·ck (the Campaign Key) in the share link, whoever receives the link can enter the same broadcast using that value.

// URI Scheme
let scheme = "{your_scheme}://{your_host}?ak=xxx&ck=yyy"
ShopLive.setShareScheme(scheme, shareDelegate: nil)

// Or a web URL
ShopLive.setShareScheme("https://www.share_link.com/?ak=xxx&ck=yyy", shareDelegate: nil)
// URI Scheme
val scheme = "{your_scheme}://{your_host}?ak=xxx&ck=yyy"
ShopLive.setShareScheme(scheme)

// Or a web URL
ShopLive.setShareScheme("https://www.share_link.com/?ak=xxx&ck=yyy")
final player = ShopLivePlayer();

player.setShareScheme(
  shareSchemeUrl: "{your_scheme}://{your_host}?ak=xxx&ck=yyy",
);
iOS The original's custom: parameter doesn't exist The real signature is setShareScheme(_ scheme: String?, shareDelegate: ShopLivePlayerShareDelegate?). The custom: label for passing a closure isn't in the SDK — using it as-is won't compile. To use the default share sheet, pass shareDelegate: nil.
iOS passing nil for both scheme and shareDelegate does nothing The SDK just logs a warning and returns immediately, so the entire share setup is ignored. Specify at least one.

Default system share UI

If you pass shareDelegate: nil on iOS and don't override handleShare on Android, the SDK shows the OS default share sheet. No extra code needed. (On iOS, shareDelegate has no default value, so it can't be omitted — you must specify nil explicitly.)

Custom share UI

To use a share screen that matches your app design, receive the share data (url · campaign info) and present it yourself.

// 1) Assign a delegate to receive the shared data
ShopLive.setShareScheme(scheme, shareDelegate: self)

// 2) Have the delegate present the app's share UI
extension MainViewController: ShopLivePlayerShareDelegate {
    func handleShare(data: ShopLivePlayerShareData) {
        guard let urlString = data.url, let url = URL(string: urlString) else { return }

        let activityVC = UIActivityViewController(activityItems: [url],
                                                  applicationActivities: nil)
        ShopLive.viewController?.present(activityVC, animated: true, completion: nil)
    }
}
// ShopLiveHandler — if you do not override it, the SDK shows its default share sheet
override fun handleShare(context: Context, data: ShopLivePlayerShareData) {
    val inflater = getSystemService(LAYOUT_INFLATER_SERVICE) as LayoutInflater
    val view = inflater.inflate(R.layout.custom_share_dialog, null)

    AlertDialog.Builder(context)
        .setView(view)
        .create()
        .show()
}
Android handleShare's second argument isn't a String The original is written as handleShare(context: Context, shareUrl: String), but no such signature exists. The real one is handleShare(context: Context, data: ShopLivePlayerShareData), and besides data.url, data.campaign(campaignKey·title·description·thumbnail) also comes along.
Flutter has no stream that delivers share data down to the app — it only lets you set the link format via setShareScheme, and the share UI uses each native platform's default share sheet as-is.
Handling what happens when the app is opened by tapping a shared link, taking the user into the broadcast, is covered in Mission 2.

Verify completion

  • Tapping the player's share button shows a share sheet containing the configured scheme/URL
  • The shared link contains the ak·ck query as-is
  • (when using a custom UI) your app's share screen appears instead of the default share sheet

Previous ← Mission 6Next → Mission 8 · PIP

Missions

Mission 8 · PIP

GoalKeep playback going in a small window if the user navigates away from the broadcast to another screen
PrerequisitesMission 1 done

There are two kinds of PIP. in-app PIP is a small window that floats only inside your own app, while OS PIP is a system window that rises above the home screen and other apps. Different things create them, so you configure them separately. What each platform supports also differs.

PlatformIn-app PIPOS PIP
iOSSupportedSupported (requires enabling Background Modes in the project)
AndroidDeprecated. Calling it does nothingSupported (Android 8.0+)
FlutterNo dedicated options. Only the transition API exists; actual behaviour follows each native platform
iOS the in-app PIP window and the preview window are the same window Both features share the position, size, corner radius and close-button settings. Tune one and the other follows, so there is no need to set the same values twice. By contrast, OS PIP is a window the operating system draws outside your app, so it is entirely separate and configured on its own. On Android in-app PIP is deprecated altogether, so there is no comparable window, and previews go through showPreviewPopup alone.

In-app PIP

Tapping the player's PIP icon or swiping down drops the broadcast into a small window inside the app. Unlike a preview, audio keeps playing. In practice only iOS and Flutter (iOS builds) can use it. On Android it is deprecated, as noted below.

// Whether PIP is allowed at all (shared precondition for in-app PIP and OS PIP)
ShopLive.setEnabledPictureInPictureMode(isEnabled: true)

// Set size, position, corners, and the close button all at once
let config = ShopLiveInAppPipConfiguration(
    useCloseButton: true,
    pipPosition: .bottomRight,
    enableSwipeOut: true,
    pipSize: ShopLiveInAppPipSize(pipMaxSize: 200),
    pipRadius: 10,
    pipPinPositions: [.topLeft, .topRight, .bottomLeft, .bottomRight],
    closeButtonConfig: ShopLiveCloseButtonConfig(position: .topRight, width: 30, height: 30, offsetX: 3, offsetY: 3, color: .white)
)
ShopLive.setInAppPipConfiguration(config: config)
final player = ShopLivePlayer();

// In-app PIP and the preview window are the same window, so one call configures both
player.setPreviewOption(
  data: ShopLivePlayerPreviewData(
    campaignKey: 'YOUR_CAMPAIGN_KEY',
    useCloseButton: true,
    enableSwipeOut: true,
    pipRadius: 10,
    pipMaxSize: 400,
    position: 'BOTTOM_RIGHT',
  ),
);

Window size is set with pipSize. pipMaxSize (based on the long edge; the other edge follows the video ratio), pipFixedWidth·pipFixedHeight — choose only one of them each have their own initialiser, so pick the one basis you want.

iOS Set the close button look with closeButtonConfig(ShopLiveCloseButtonConfig). All fields are optional, and the shadow fields have no default.

FieldTypeDefaultDescription
position.topLeft · .topRight.topLeftClose button position
width · heightCGFloat30Button size
offsetX · offsetYCGFloat3Distance from the corner
colorUIColor.whiteButton color
shadowOffsetX · shadowOffsetYCGFloatnoneShadow offset
shadowBlurCGFloatnoneShadow blur radius
shadowBlurStyle.normal · .solid · .inner · .outernoneShadow blur style
shadowColorUIColornoneShadow color
imageStrStringnoneButton image URL
iOS turning PIP off closes the player instead of shrinking it setEnabledPictureInPictureMode defaults to true. Set it to false and, when a PIP transition is requested, the player shuts down instead of shrinking. This is not "disable PIP but keep fullscreen", so be careful.
Android in-app PIP is deprecated showInAppPip()·hideInAppPip() do nothing when called, and isInAppPipMode() always returns false. The second argument of setEnabledPictureInPictureMode(isEnabled, useInAppPip) is ignored too. If you need a small window on Android, use OS PIP instead.
Flutter the settings are shared with preview There is no dedicated in-app PIP API. A single setPreviewOption(ShopLivePlayerPreviewData) maps to setInAppPipConfiguration and setPictureInPicturePadding on iOS and to ShopLivePreviewData on Android. It is the same call as the preview settings in Mission 9, so you only need to set it once.

There are also things Flutter simply does not have. The OS PIP toggle (setEnabledPictureInPictureMode family) and the Android overlay-permission API (ShopLivePermissionManager) are missing from the Flutter SDK, so you have to call native code directly if you need them.

If you integrated from the old docs: three APIs that are gone

pipScale·setEnabledPipSwipeOut·ShopLive.useCloseButton(_:) are deprecated. All three were folded into the single setInAppPipConfiguration(config:) call shown above. Size moves to pipSize, swipe-to-dismiss to enableSwipeOut, and the close button via the useCloseButton property property. The settings themselves did not disappear; only the old style of calling them one by one as global functions did.

OS PIP

Playback keeps going in a system window even if the user goes to the home screen or switches to another app.

iOS In your Xcode project, turn on Signing & Capabilities → Background Modes's Audio, AirPlay, and Picture in Picture for this to work. Miss it and the player will not drop into OS PIP, no matter how correct your code is.

Xcode Background Modes setting
Background Modes enabled in Xcode
ShopLive.setEnabledOSPictureInPictureMode(isEnabled: true)
OS PIP also requires setEnabledPictureInPictureMode to be on The two flags are an AND condition. If setEnabledPictureInPictureMode(isEnabled: false), even calling setEnabledOSPictureInPictureMode(isEnabled: true) won't create the OS PIP controller. You can't set up "only in-app PIP off, but OS PIP on."
iOS you must include an explanation with your App Store submission Using OS PIP turns on the background audio entitlement, so your App Store submission has to state why you need it and a path the reviewer can follow to reproduce PIP themselves (a screen path that opens a broadcast, or a deep link). Submitting without this can get you rejected.

Android OS PIP is on by default. You only need to set the aspect ratio to suit your service.

// Allow entering OS PIP (default true)
ShopLive.setEnabledPictureInPictureMode(true)

// PIP window ratio — defaults to 9:16 if not set
// RATIO_1X1 / RATIO_1X2 / RATIO_2X3 / RATIO_3X4 / RATIO_9X16
ShopLive.setPIPRatio(ShopLivePIPRatio.RATIO_9X16)
Android nothing extra to add to the Manifest The player Activity's android:supportsPictureInPicture="true" and SYSTEM_ALERT_WINDOW permission are already declared in the SDK module's Manifest, so they're merged into your app via Manifest merging.
Android request the "Display over other apps" permission ahead of time A PIP switch can sometimes fail due to background-activity-launch (BAL) restrictions. Requesting overlay permission with the helper below avoids this problem.
ShopLivePermissionManager.request(this, ShopLivePermissionType.DRAW_OVERLAY) { hasPermission ->
    // hasPermission == true means overlay permission is already granted
}

This helper is a separate module, so you need to add a dependency to use it.

// app/build.gradle
implementation "cloud.shoplive:shoplive-permission:$shoplive_sdk_version"
Android it does not fail silently on devices or settings where PIP is unavailable OS PIP itself requires Android 8.0 (API 26)+, so requesting a switch on an older version pops up a notice dialog, or UNSUPPORTED_OS_PIP_MODE (9500) error is delivered via onError. Even on 8.0 and above, if PIP is switched off for your app in the system settings the SDK shows a dialog asking whether to open the settings screen. Users see this flow, so check it in advance.

PIP switching API

Besides user interaction (PIP button · swipe), your app code can switch to PIP or back to full screen directly. Use it to briefly set the player aside, e.g. right before navigating to a login screen.

// Switch to in-app PIP
ShopLive.startPictureInPicture()

// Return to full screen
ShopLive.stopPictureInPicture()
// Switch to OS PIP — set true if you need to wait for the overlay permission screen to close
ShopLive.startPictureInPicture(shouldDelayForOverlayPermissionFlow = false)

// Return to full screen
ShopLive.stopPictureInPicture()
final player = ShopLivePlayer();

player.startPictureInPicture();
player.stopPictureInPicture();
Android the zero-argument startPictureInPicture() is deprecated startPictureInPicture(shouldDelayForOverlayPermissionFlow: Boolean) should be used instead. Passing true leaves a brief moment for a permission dialog to clear before dropping into PIP.
Flutter's PIP just follows native behavior as-is The plugin calls in-app PIP on iOS and OS PIP on Android. The same Dart code produces different results on the two operating systems.

iOS only PIP movement area and padding

To keep in-app PIP from covering the bottom tab bar or an essential button, you can restrict both the area it can move within and PIP's own margin.

App tab bar
offset.top offset.bottom
LIVE
padding
The area PIP may enter (purple dashed) is set with offset, and the padding of the PIP window itself (grey dashed) with padding.
// Area near the screen edges where PIP should not be allowed to enter
let offsetApplied = ShopLive.setPictureInPictureFloatingOffset(
    offset: UIEdgeInsets(top: 60, left: 0, bottom: 90, right: 0))

// Margin around the PIP itself
let paddingApplied = ShopLive.setPictureInPicturePadding(
    padding: UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8))
Both functions return a Bool. Always check the return value If the combined offset and padding area exceeds the screen's width or height, the value isn't applied and it returns false. If you ignore this, it just runs with the setting silently dropped.

Verify completion

  • (iOS) Swiping down on the player shrinks it into a small window inside the app, and audio doesn't drop
  • (iOS) After enabling Background Modes, going to the home screen continues playback in the system PIP window
  • (Android) Going to the home screen switches to an OS PIP window of the configured aspect ratio
  • startPictureInPicture·stopPictureInPicture correctly switches to and back from PIP
  • (iOS, when offsets are configured) both functions return true and PIP never goes outside the configured area

Previous ← Mission 7Next → Mission 9 · Broadcast preview

Missions

Mission 9 · Broadcast preview

GoalPlay a broadcast muted in a small window, without entering the player, to draw the user in to watch
PrerequisitesMission 1 completed. The Access Key must already be set

The preview is muted by default, and tapping the window switches it to the full-screen player. On Android, floating it over Activity with Popup Preview is the recommended approach.

iOS In-app PIP and the preview window are the same window Both features share the position, size, corner radius and close-button settings; tune one and the other follows. The OS PIP is separate that appears outside the app is separate and configured on its own. On Android in-app PIP is deprecated, so everything goes through showPreviewPopup alone.

Starting a preview

let previewData = ShopLivePreviewData(
    campaignKey: "YOUR_CAMPAIGN_KEY",
    keepWindowStateOnPlayExecuted: true,
    referrer: nil,
    isMuted: true,
    isEnabledVolumeKey: false,
    resolution: .PREVIEW
)

ShopLive.preview(data: previewData) {
    // To go full screen when the preview window is tapped
    ShopLive.play(data: .init(campaignKey: "YOUR_CAMPAIGN_KEY"))
}
ShopLive.showPreviewPopup(this, ShopLivePreviewData("YOUR_CAMPAIGN_KEY").apply {
    useCloseButton = true
    enabledSwipeOut = true
    resolutionType = ShopLivePreviewResolutionType.LIVE

    onClickListener = View.OnClickListener { view ->
        // Preview → player transition animation
        ShopLive.setPreviewTransitionAnimation(this@MainActivity, view)
        ShopLive.play(this@MainActivity, ShopLivePlayerData("YOUR_CAMPAIGN_KEY"))
    }
})
final player = ShopLivePlayer();

// 1) Set the options first
player.setPreviewOption(
  data: ShopLivePlayerPreviewData(
    campaignKey: 'YOUR_CAMPAIGN_KEY',
    useCloseButton: true,
    enableSwipeOut: true,
    pipRadius: 10,
    pipMaxSize: 400,
    position: 'BOTTOM_RIGHT',
  ),
);

// 2) Start the preview
player.showPreview(campaignKey: 'YOUR_CAMPAIGN_KEY');

Call the following when you need to close the preview from code. Call it at the moment it should close, such as when navigating to another screen; not right after showing it.

player.hidePreview();
This is where each platform usually trips people up

iOS preview(data:) The completion attached to it fires when playback starts not when the user taps the preview window, but when the viewer taps the preview window. And the moment you pass a completion, the SDK's default "tap to go fullscreen" behaviour disappears. If you want the fullscreen transition, call play(data:) yourself inside the completion, as in the example above; to keep the default behaviour, pass nil to completion.

Flutter Set the options first, show the preview second. showPreview takes only a campaignKey (showPreview({required String campaignKey})), and options are passed separately through setPreviewOption. Reverse the order and the options are ignored. The showPreview(data:) that appears in older docs does not exist.

Android ShopLivePreviewData(campaignKey) Use the single-argument form. ShopLivePreviewData(activity, accessKey, campaignKey) has all three of its arguments deprecated. Set the accessKey ahead of time with Mission 1 is an example of how you must ShopLiveCommon.setAccessKey, as in that mission.

Android showPreviewPopup can return null If the player is already up or the target Activity is finishing, it skips showing the preview and returns null. If your code uses the return value, don't skip handling null processing.

See the full option list and defaults in the Android ShopLivePreviewData properties · Flutter ShopLivePlayerPreviewData tables.

Android showing it with saved options

If you save the preview options once at app startup, you can later launch it by just passing campaignKey.

// Save the options once — campaignKey gets overwritten by the value passed to showPreviewPopup
ShopLive.setPreviewOptions(ShopLivePreviewData("YOUR_CAMPAIGN_KEY").apply {
    useCloseButton = true
    enabledSwipeOut = true
})

// Only campaignKey is needed to present it
ShopLive.showPreviewPopup(this, "YOUR_CAMPAIGN_KEY")
Android ShopLivePreviewData("") like this — don't pass an empty campaignKey The initializer validates campaignKey, and if it's empty, it throws an NOT_INITIALIZED_CAMPAIGN_KEY error via onError. Use a real campaignKey even when you're only pre-saving options.

Banner preview view — embed it in your layout

The Popup Preview shown so far is a window floating window. If instead you want to embed it directly it into your layout, for example to attach a preview to every cell in a list, use a preview view. On iOS it is ShopLivePlayerPreview, on Android cloud.shoplive.sdk.ShopLivePreview. Flutter has no such embeddable view.

iOS Create the view, call addSubview, add constraints, then call .initialize → .start(accessKey:campaignKey:referrer:) in that order. Events arrive through the resultHandler closure. The full list of cases is in Preview components. When a cell is reused, clean up with .stop or .close.

Android Place the view in your layout XML, then start and release it in code.

<cloud.shoplive.sdk.ShopLivePreview
    android:id="@+id/preview"
    android:layout_width="120dp"
    android:layout_height="180dp" />
import ShopLiveSDK
import ShopliveSDKCommon

final class PreviewCell: UITableViewCell {
    private var preview: ShopLivePlayerPreview?

    func configure(campaignKey: String) {
        let preview = ShopLivePlayerPreview()
        preview.translatesAutoresizingMaskIntoConstraints = false
        contentView.addSubview(preview)
        NSLayoutConstraint.activate([
            preview.topAnchor.constraint(equalTo: contentView.topAnchor),
            preview.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            preview.widthAnchor.constraint(equalToConstant: 120),
            preview.heightAnchor.constraint(equalToConstant: 180)
        ])
        self.preview = preview

        preview.action(.initialize)
        preview.resultHandler = { result in
            switch result {
            case .didChangeCampaignStatus(let status):
                print(status)
            case .onError(let code, let message):
                print(code, message)
            case .handleReceivedCommand(let command, let payload):
                print(command, payload ?? [:])
            default:
                break   // for other cases, see the Preview components reference
            }
        }
        // replaces the global accessKey with this value
        preview.action(.start(accessKey: "{ACCESS_KEY}", campaignKey: campaignKey, referrer: nil))
    }

    override func prepareForReuse() {
        super.prepareForReuse()
        preview?.action(.close)   // clean up on cell reuse — removes it from the parent view
        preview = nil
    }
}
// owner is the LifecycleOwner (host Activity/Fragment) received in the adapter constructor
class PreviewAdapter(
    private val owner: LifecycleOwner,
    private val campaignKeys: List<String>,
) : RecyclerView.Adapter<PreviewAdapter.ViewHolder>() {

    override fun getItemCount() = campaignKeys.size

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val view = LayoutInflater.from(parent.context)
            .inflate(R.layout.item_preview, parent, false)
        return ViewHolder(view)
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        holder.onBind(campaignKeys[position])
    }

    // Starts/releases playback as RecyclerView attaches and detaches the cell
    override fun onViewAttachedToWindow(holder: ViewHolder) {
        super.onViewAttachedToWindow(holder)
        holder.onViewAttachedToWindow()
    }

    override fun onViewDetachedFromWindow(holder: ViewHolder) {
        holder.onViewDetachedFromWindow()
        super.onViewDetachedFromWindow(holder)
    }

    inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
        private val preview: ShopLivePreview = view.findViewById(R.id.preview)

        fun onBind(campaignKey: String) {
            preview.initializePlayer()
            preview.setLifecycleObserver(owner)
            preview.campaignKey = campaignKey
            preview.setMuted(true)
        }

        fun onViewAttachedToWindow() {
            preview.start() // Uses the accessKey already set via ShopLiveCommon.setAccessKey in Mission 1
        }

        fun onViewDetachedFromWindow() {
            preview.release()
        }
    }
}
Android ShopLivePreview view's start() and prepare() can throw accessKey and campaignKey not being set — calling the parameterless start() or prepare() raises ShopLiveCommonError.
Android RecyclerView: when using it When the ViewHolder attaches, call preview.start(); and when it detaches, call preview.release(). Since cells are recycled, if you don't also call release() on the previous instance, a preview scrolled off screen keeps playing.
Android This banner can also silently overwrite the popup size saved above The banner view and the Popup Preview internally are the same ShopLivePreview class. The popup is just a wrapper that inflates this same view. When it receives an aspect ratio (an internal callback that runs identically for both banner and popup), it writes that size back into the ShopLive.setPreviewOptions width/height stored via that call. It is a one-directional side effect that only happens when a stored value exists. The popup itself goes down the same path while playing, but that just rewrites its own values so it goes unnoticed; a banner-style instance that stays up without popup UI, the saved value can change without the user noticing.

Verify completion

  • The preview window opens muted and plays the broadcast video
  • Tapping the window switches to the full-screen player
  • The preview closes via the close button or by swiping it off screen
  • (Android) Requesting a preview while the player is already running doesn't create a duplicate
  • The banner preview view (iOS ShopLivePlayerPreview · Android ShopLivePreview) starts and releases correctly as you scroll, even inside a list

Previous ← Mission 8Next → Mission 10 · Purchase conversion tracking

Missions

Mission 10 · Purchase conversion tracking

GoalSend a conversion event at the moment a purchase is confirmed, to record "this person bought after watching this broadcast"
PrerequisitesMission 3 completed. The identifier that ties the viewer and the buyer together must already be set

Shoplive records a purchase conversion when a user watches a live (or replay) or shortform video for at least 3 seconds and then buys a product registered on that video within 28 days of watching. A conversion is attributed to the last video watched, and live (replay) and shortform are counted separately, so a single purchase can be recorded as a conversion on both sides.

Sending the conversion event at purchase completion

On the screen where the purchase is confirmed (e.g. order complete page), call sendConversionEvent once. When the product identifiers you pass map to the watch record, the link between viewing and purchase is recorded.

import ShopliveSDKCommon

let products = [
    ShopLiveConversionProductData(
        productId: "productId",
        customerProductId: "customerProductId",
        sku: "sku",
        url: "url",
        purchaseQuantity: 1,
        purchaseUnitPrice: 1000.0)
]

ShopLiveEvent.sendConversionEvent(data: .init(
    type: "purchase",
    products: products,
    orderId: "orderId",
    referrer: "referrer",
    custom: ["campaignKey": "campaignKey"]))
ShopLiveEvent.sendConversionEvent(
    ShopLiveConversionData(
        "purchase",
        products = listOf(
            ShopLiveConversionProductData(
                productId = "productId",
                customerProductId = "customerProductId",
                sku = "sku",
                url = "url",
                purchaseQuantity = 1,
                purchaseUnitPrice = 1000.0,
            )
        ),
        orderId = "orderId",
        referrer = "referrer",
        custom = mapOf("campaignKey" to "campaignKey"),
    )
)
ShopLiveConversionDataTypeDescription
typeString?Purchase conversion is "purchase" · required on Android
productsList<ShopLiveConversionProductData>?List of purchased products · required on Android
orderIdString?Order number
referrerString?Inflow channel
customMap<String, Any?>?A value to attach alongside it (e.g. campaignKey)
ShopLiveConversionProductDataTypeDescription
productIdString?Shoplive's own product ID
customerProductIdString?Client's own product ID
skuString?Product SKU
urlString?Product URL
purchaseQuantityInt?Quantity purchased
purchaseUnitPriceDouble?Unit price
Android type and products can't be omitted Only these two arguments have no default value, so you must specify null explicitly to compile, even if you have nothing to pass. All arguments in the rest (orderId·referrer·custom) and ShopLiveConversionProductData have default values, so you can omit them. On iOS, all of them have default values, so pass only what you need.
orderId is a string, not a number The original table writes it as Int?, but the real definition on both iOS and Android is String?. Even if the order number consists only of digits, pass it as a string.
Android purchaseUnitPrice won't compile if you use an integer literal The type is Double?, but Kotlin doesn't automatically convert 1000 to Double. Write 1000.0.
Flutter has no conversion event API Flutter shoplive_player 1.9.0 does not include sendConversionEvent · setAnonId. The Dart example in older docs (ShopLiveEvent.sendConversionEvent) does not compile. If you need them, call the examples above from native iOS · Android code (platform channel).
The demo app sends it at product-click time, for testing Inside the product click event (onEvent), the iOS/Android demo apps call sendConversionEvent right away, to verify conversions accumulate correctly. In a real service, call it only once, after the purchase is confirmed. Sending it on every click records products that were never bought as conversions.

Tying the viewer and the buyer together as the same person

A conversion is only recorded if "this person watched, and this person bought" is a continuous chain. The conversion event reads and sends the user identifier at the moment it's called, so the same value used while watching must also be set at purchase time.

SituationWhat to set
Signed-in userMission 3 the same way: setUser or setAuthToken
Not signed in, but the app manages its own device identifiersetAnonId lets you pass that value
Not signed in, and no identifier of its own eitherNothing to set. The SDK links them using an identifier it has stored
// Logged-in user — same as when watching
ShopLiveCommon.setUser(user: ShopLiveCommonUser(userId: "{USER_ID}"))
// Or a JWT signed by your backend
ShopLiveCommon.setAuthToken(authToken: "{USER_JWT}")

// Not logged in — only if the app manages a device identifier
ShopLiveCommon.setAnonId(anonId: "{DEVICE_ID}")
// Logged-in user — same as when watching
ShopLive.setUser(ShopLiveCommonUser(userId = "{USER_ID}"))
// Or a JWT signed by your backend
ShopLiveCommon.setAuthToken("{USER_JWT}")

// Not logged in — only if the app manages a device identifier
ShopLiveCommon.setAnonId("{DEVICE_ID}")
Flutter setAnonId is not available Flutter shoplive_player 1.9.0 does not include sendConversionEvent · setAnonId. The Dart example in older docs (ShopLiveEvent.sendConversionEvent) does not compile. If you need them, call the examples above from native iOS · Android code (platform channel).

Verify completion

  • On the purchase-complete screen, sendConversionEvent is called exactly once
  • At call time, the same user identifier used while watching (userId·JWT·anonId) is set
  • orderId as a string, and purchaseUnitPrice as a float, and the build still passes

Previous ← Mission 9Next → Mission 11 · UI and behavior customization

Missions

Mission 11 · UI and behavior customization

GoalCustomize player details like sound, loading screen, and post-link-click behavior to match your service
PrerequisitesMission 1 completed. Everything here is optional, so pick only what you need

Keeping the mute state

If a user turns on mute, leaves the player, and comes back, it resets to the default (sound on). Capture the mute state via the event callback and restore it on the next playback.

// ShopLiveSDKDelegate
private var isMuted = false

func onEvent(name: String, feature: ShopLiveLog.Feature, campaign: String, payload: [String: Any]) {
    switch name {
    case "video_muted":
        isMuted = true
    case "video_unmuted":
        isMuted = false
    default:
        break
    }
}
// ShopLiveHandler
private var isMuted = false

override fun onEvent(context: Context, data: ShopLiveLog.Data) {
    when (data.name) {
        "video_muted" -> isMuted = true
        "video_unmuted" -> isMuted = false
    }
}
final player = ShopLivePlayer();
bool isMuted = false;

player.log.listen((data) {
  switch (data.name) {
    case "video_muted":
      isMuted = true;
      break;
    case "video_unmuted":
      isMuted = false;
      break;
  }
});

Pass the saved value before the next playback.

ShopLive.setMuteWhenPlayStart(isMuted)
ShopLive.play(data: .init(campaignKey: "{CAMPAIGN_KEY}"))
ShopLive.setMuteWhenPlayStart(isMuted)
ShopLive.play(this, ShopLivePlayerData("{CAMPAIGN_KEY}"))
final player = ShopLivePlayer();

player.setMuteWhenPlayStart(isMute: isMuted);
player.play(data: ShopLivePlayerData(campaignKey: "{CAMPAIGN_KEY}"));
Receive event logs through onEvent The log callback the old guide points to (iOS log(name:feature:campaign:payload:) · Android log(data:)) is deprecated in both SDKs. For a new integration, use onEvent should be used instead. Passing

Flutter is the exception. The Flutter SDK has no onEvent stream — log is the right stream.

Player behavior after a link click

Choose what happens to the player left behind when the app switches to another screen after the user taps a product/notice/banner link.

ValueBehavior
PIP (default)Shrinks into a small window and the broadcast keeps playing
KEEPStays in full screen as-is
CLOSEPlayer closes
// Pass one of the values from the table above (.PIP · .KEEP · .CLOSE)
ShopLive.setNextActionOnHandleNavigation(actionType: .PIP)
// Pass one of the values from the table above (PIP · KEEP · CLOSE)
ShopLive.setNextActionOnHandleNavigation(ActionType.PIP)
iOS don't drop the actionType: The original example's ShopLive.setNextActionOnHandleNavigation(.PIP) won't compile. The real signature is setNextActionOnHandleNavigation(actionType: ActionType).
Flutter the method exists under the same name but the setting has no effect ShopLivePlayer has setNextActionOnHandleNavigation on it, so it even shows up in autocomplete, but the value you pass never reaches the native side. Dart sends a 'show'·'hide'·'keep' string (since ShopLiveActionType.parseValue()'s return type is String), but the native side reads this value as an integer. So in practice, Android always behaves as PIP no matter what you pass, and iOS does nothing at all (keeps the default PIP). The Dart enum being named show·hide·keep — different from the table's PIP·KEEP·CLOSE above even in name — is a sign of the same underlying problem. If you need this behavior in Flutter, contact your Shoplive representative.
iOS hookNavigation ignores this setting Mission 6's hookNavigation means the SDK only calls that closure and skips the post-link-click behavior entirely. Don't expect both mechanisms at once.

Brand like (follow)

Show a brand image and a like (follow) button inside the player, and keep its state in sync with the user data your app holds.

You need to coordinate with Shoplive first The button only appears once the feature is switched on for the campaign. Contact ask@shoplive.cloud to request it.

1) Sync the follow state on first entry. Send the follow state your app already knows through the SET_BRAND_FAVORITE command, and the player's button display updates to that value. The 2) below also uses the same command.

ShopLive.sendCommandMessage(
    command: "SET_BRAND_FAVORITE",
    payload: ["identifier": "brand-identifier", "favorite": true])
ShopLive.sendCommandMessage(
    "SET_BRAND_FAVORITE",
    mapOf("identifier" to "brand-identifier", "favorite" to true)
)
The user info's custom doesn't change the follow state The original Android guide shows an example that passes the initial state into custom by putting a brand key there, but the current web player doesn't read the key inside custom. custom is a value carried in user info to your backend — it doesn't change the player UI (Mission 3's field, mentioned above). Send the initial state via the SET_BRAND_FAVORITE above too. Also, that original example's ShopLiveUser and addCustomParameter are deprecated. Pass user information as Mission 3's ShopLiveCommonUser as usual.
When exactly to send it is not confirmed. Needs verification This command must be sent after the player has launched. Call it any earlier and it is silently dropped. On iOS, if the player webview does not exist yet the send is skipped entirely; on Android, before initialisation it just logs "not initialized" and returns. Neither gives you an error callback, so a failure is hard to notice. That said, which callback makes it safe is not confirmed. Confirm this with your Shoplive contact when you start integrating, and until then verify by eye on a real campaign.

2) Receive and handle the button tap. Mission 6 has the same command callback. Follow and unfollow are the same event. There is no separate command name; a single favorite just carries a single boolean. Your app's job is to handle your own follow logic, then send back the resulting confirmed state via SET_BRAND_FAVORITE.

ON_CLICK_BRAND_FAVORITE_BUTTON payloadTypeWhen it arrives
identifierStringAlways
favoriteBoolAlways present. That said, whether it's the pre-click or post-click state depends on the campaign theme (see the note below)
brandString (brand name)Only for some campaign themes. It may be absent, so don't rely on it
nameStringbrand: same condition, same value
func handleReceivedCommand(_ command: String, data: [String: Any]?) {
    switch command {
    case "ON_CLICK_BRAND_FAVORITE_BUTTON":
        guard let identifier = data?["identifier"] as? String else {
            return
        }
        // Handle follow in your app/backend, then send back the confirmed final state as-is
        updateFavorite(identifier: identifier) { isFavorite in
            ShopLive.sendCommandMessage(
                command: "SET_BRAND_FAVORITE",
                payload: ["identifier": identifier, "favorite": isFavorite])
        }
    case "ON_CHANGED_BRAND_FAVORITE":
        // Reports the result once the follow state has changed
        break
    default:
        break
    }
}
override fun onReceivedCommand(context: Context, command: String, data: JSONObject) {
    when (command) {
        "ON_CLICK_BRAND_FAVORITE_BUTTON" -> {
            val identifier = data.getString("identifier")
            // Handle follow in your app/backend, then send back the confirmed final state as-is
            updateFavorite(identifier) { isFavorite ->
                ShopLive.sendCommandMessage(
                    "SET_BRAND_FAVORITE",
                    mapOf("identifier" to identifier, "favorite" to isFavorite)
                )
            }
        }
        "ON_CHANGED_BRAND_FAVORITE" -> {
            // Reports the result once the follow state has changed
            val identifier = data.getString("identifier")
        }
    }
}
Don't compute the final state from the received favorite Whether this value is the pre-click state or the desired post-click state depends on the campaign theme. So sending back the inverted value (!favorite) does the exact opposite once the theme changes. On a theme where the player has already updated the heart, it overwrites that update with the old state and the heart flips back. SET_BRAND_FAVORITE sets "the state to display" as an absolute value, so the app should just send exactly the state its own backend has confirmed, and it will be correct regardless of theme. Base what you display on the follow state your app holds, too.
In some cases you don't need to send it back at all Some campaign themes update the heart display in the player itself, right when the button is tapped. In that case, the screen is already correct even if the app doesn't send SET_BRAND_FAVORITE. Only send it when needed — e.g. to roll back after a failed backend call — and still send it as an absolute value.
Android passing the payload as a JSON string is deprecated The original's sendCommandMessage("SET_BRAND_FAVORITE", "{...}") is marked deprecated. Map — use the version that takes it. The one the deprecation message points to, ShopLiveCommon.sendCommandMessage does not actually exist. ShopLive is the correct one.
iOS payload — passing nil to it sends nothing at all sendCommandMessage(command:payload:) simply returns as-is if the payload is nil. Even if you have nothing to send, pass an empty dictionary.

When embedding Shoplive in your app's own web view

This setting is needed when your app builds its own web view to embed the Shoplive web player, instead of using the SDK.

iOS WKWebViewConfiguration's allowsInlineMediaPlayback must be true so the video doesn't jump to full screen and instead plays inline in the page. Shoplive serves based on WKWebView, and doesn't support UIWebView, which Apple has ended support for.

Android shouldOverrideUrlLoading: don't call webView.loadUrl(url). Only branch like below when you need to handle a custom scheme separately.

let configuration = WKWebViewConfiguration()
configuration.allowsInlineMediaPlayback = true
let webView = WKWebView(frame: view.bounds, configuration: configuration)
webView.webViewClient = object : WebViewClient() {
    override fun shouldOverrideUrlLoading(
        view: WebView?,
        request: WebResourceRequest?
    ): Boolean {
        // Handle it here only if you have a separate scheme
        val url = request?.url ?: return super.shouldOverrideUrlLoading(view, request)
        if (url.toString().startsWith("market")) {
            val intent = Intent(Intent.ACTION_VIEW).apply {
                data = url
            }
            startActivity(intent)
            return true
        }
        return super.shouldOverrideUrlLoading(view, request)
    }
}

When a user taps a product, the new-window behavior is received via WebChromeClient.onCreateWindow. Below is an example that continues opening it in the same web view you're currently viewing.

webView.webChromeClient = object : WebChromeClient() {
    override fun onCreateWindow(
        view: WebView?,
        isDialog: Boolean,
        isUserGesture: Boolean,
        resultMsg: Message?
    ): Boolean {
        view ?: return true
        resultMsg ?: return true
        val subWebView = WebView(view.context)
        subWebView.webViewClient = object : WebViewClient() {
            // If you support devices on API 23 or lower, also implement the String overload
            override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean {
                url ?: return false
                if (url.startsWith("http")) {
                    webView.loadUrl(url)
                }
                return true
            }
            override fun shouldOverrideUrlLoading(
                view: WebView?,
                request: WebResourceRequest?
            ): Boolean {
                val url = request?.url?.toString() ?: return false
                if (url.startsWith("http")) {
                    webView.loadUrl(url)
                }
                return true
            }
        }
        val viewTransport = (resultMsg.obj as? WebView.WebViewTransport) ?: return true
        viewTransport.webView = subWebView
        resultMsg.sendToTarget()
        return true
    }
}

If you want to open it in an external browser or a new window, inside the two shouldOverrideUrlLoading blocks, swap out the webView.loadUrl(url) spot with the one below.

val intent = Intent(Intent.ACTION_VIEW).apply {
    data = Uri.parse(url)
}
startActivity(intent)

Changing the loading indicator

The indicator shown before the video appears after entering a broadcast. The default is white.

Just changing the color

ShopLive.indicatorColor = UIColor.purple
ShopLive.setLoadingProgressColor(Color.RED)
Color-changed loading indicator
Indicator color change

Switching to an image animation

var images: [UIImage] = []

for i in 1...11 {
    images.append(.init(named: "loading\(i)")!)
}

ShopLive.setLoadingAnimation(images: images)
ShopLive.setLoadingAnimation(R.drawable.progress_animation)

Android first defines the frames as a animation-list drawable first. res/drawable/progress_animation.xml:

<?xml version="1.0" encoding="utf-8"?>
<animation-list xmlns:android="http://schemas.android.com/apk/res/android"
    android:oneshot="false">

    <item android:drawable="@drawable/loading1" android:duration="100" />
    <item android:drawable="@drawable/loading2" android:duration="100" />
    <item android:drawable="@drawable/loading3" android:duration="100" />
    <item android:drawable="@drawable/loading4" android:duration="100" />
</animation-list>
Color and image animation don't apply together On Android, setting one resets the other, so only whichever you called last sticks around. On iOS, as soon as you add even a single image, the image animation takes priority and the color setting is unused. Use only one of the two.

Behavior after a call/earphone interruption

If a call comes in or the earphones are unplugged during playback, the video stops. Choose what happens once the interruption ends.

// Keep playing even if headphones are unplugged (default false — stop)
ShopLive.setKeepPlayVideoOnHeadphoneUnplugged(true)
// Keep playing, but muted
ShopLive.setKeepPlayVideoOnHeadphoneUnplugged(true, isMute: true)
print(ShopLive.isKeepPlayVideoOnHeadPhoneUnplugged())

// Automatically resume playback when the call ends (iOS default false — stays stopped)
ShopLive.setAutoResumeVideoOnCallEnded(true)
print(ShopLive.isAutoResumeVideoOnCallEnded())
// Keep playing even if headphones are unplugged (default false — stop)
ShopLive.setKeepPlayVideoOnHeadphoneUnplugged(true, false)
// Keep playing, but muted
ShopLive.setKeepPlayVideoOnHeadphoneUnplugged(true, true)

// Automatically resume playback when the call ends (Android default true)
ShopLive.setAutoResumeVideoOnCallEnded(false)
The default for auto-resume after a call differs by platform On iOS it's false (stays paused), and on Android it's true (auto-resume). To make both platforms behave the same, set it explicitly on both.
Android setKeepPlayVideoOnHeadphoneUnplugged needs both arguments The original example's single-argument call (setKeepPlayVideoOnHeadphoneUnplugged(true)) won't compile. The second argument is "whether to keep playing, but muted." On iOS, the second argument has a default value, so you can omit it.
Android call detection needs android.permission.READ_PHONE_STATE permission Without the permission, the SDK can't detect a call-triggered interruption, so the auto-resume setting doesn't work either.
Android Earphone disconnect handling needs no permission The SDK detects earphone (including Bluetooth) disconnects through the system broadcast ACTION_AUDIO_BECOMING_NOISY and pauses or mutes playback. This broadcast needs no permission, so you do not need to request the BLUETOOTH_CONNECT runtime permission. Whether Bluetooth-disconnect muting still works on Android 12+ devices needs verification.

Verify completion

  • Turning on mute and leaving/re-entering the player keeps mute on
  • (iOS/Android) Tapping a product link behaves as configured — PIP, stay full screen, or close
  • (if using brand like) Tapping the button delivers ON_CLICK_BRAND_FAVORITE_BUTTON, and the state you send back is reflected in the button
  • The loading indicator shows the configured color or image animation (you didn't set both at once)
  • Unplugging earphones or answering/ending a call plays or pauses playback as configured

Previous ← Mission 10

Reference

Data field dictionary

A complete listing of the fields in the data types actually exchanged with the play and preview APIs. Even for the same concept, field layouts differ between platforms in places, and the tables show that as it is. A blank cell does not mean "not written up yet"; it means that field doesn't exist on that platform.

PlayerData: what you pass when starting playback

iOS ShopLivePlayerData

FieldTypenullableMeaning
campaignKeyStringNoBroadcast identifier key. Required constructor argument
keepWindowStateOnPlayExecutedBoolNo (default true)Whether to keep the existing window state when play runs
referrerString?YesInflow channel
campaignHandler((ShopLivePlayerCampaign) -> ())?YesCampaign info callback (shared by play/preview)
brandHandler((ShopLivePlayerBrand) -> ())?YesBrand info callback (shared by play/preview)
isEnabledVolumeKeyBoolNo (default false)Whether volume-key control is allowed

Android ShopLivePlayerData

FieldTypenullableMeaning
campaignKeyStringNoRequired constructor argument
keepWindowStateOnPlayExecutedBooleanNo (default true)Same concept as iOS, with a separate setter
referrerString?YesHas a separate setter
resizeModeShopLiveResizeMode?YesField not present on iOS PlayerData. Values: AUTO / CENTER_CROP / FIT

Flutter ShopLivePlayerData

FieldTypenullableMeaning
campaignKeyStringNoRequired constructor argument
keepWindowStateOnPlayExecutedboolNo (default false)Whether to keep the existing window state when play runs
referrerString?YesInflow channel
Flutter keepWindowStateOnPlayExecuted defaults to the opposite of native In Flutter the default is false, so if you pass nothing it opens in fullscreen every time, the opposite of the native default (true). To match native, set true explicitly.
On Android, the main player can't receive Brand info as a structured callback On iOS, PlayerData itself has a campaignHandler·brandHandler closure, so the main (fullscreen) player also receives typed Campaign and Brand callbacks. Android is different: the typed Brand callback is Preview only(ShopLivePreviewData's onCampaignListener), and the main player's campaign info comes not from PlayerData but from the global ShopLiveHandler.onCampaignInfo(JSONObject) (raw JSON), and brand info has no equivalent at all on the main player path. Saying "brand info can also be received as a structured callback on the main player" would be incorrect for Android.

Campaign (ShopLivePlayerCampaign)

iOS

FieldTypenullableMeaning
titleString?YesCampaign title
campaignStatusShopLivePlayerCampaignStatusNo (default READY)Only 3 values arrive (READY · ONAIR · CLOSED). Any string that does not match falls back to READY

Android

FieldTypenullableMeaning
titleString?YesSame concept
campaignStatusShopLivePlayerCampaignStatus?Yes4 values: READY / ONAIR / CLOSING / CLOSED
The iOS enum has no CLOSING Android's server status values have one extra value that iOS doesn't: CLOSING. iOS drops every non-matching string into READY, so if the server sends "CLOSING", the iOS client mistakes it for READY (broadcast pending) — confirmed to behave this way in the code. Whether the server actually sends CLOSING to iOS too is a server-contract question, so it's needs verification.

Brand (ShopLivePlayerBrand)

Brand information delivered by a callback during playback. The iOS and Android field layouts are identical. As noted under PlayerData above, Android can't receive this type via the main player callback, and it's Preview-only.

FieldTypenullableMeaning
nameString?YesBrand name
identifierString?YesBrand identifier
imageUrlString?YesBrand image URL
iOS Shortform has a separate type (BrandData/SLBrandModel, with a different field layout). This table covers specific to ShopLivePlayerBrand for Live playback, and Shortform Brand is out of scope.

CommonUser (ShopLiveCommonUser)

Both iOS and Android use the same name (ShopLiveCommonUser). All 6 fields map 1:1 with the same values, but the enum type names and case spellings differ between platforms. Copy the spellings below exactly when writing code. For the full integration steps see Mission 3.

FieldTypenullableMeaning
userIdStringNoRequired
userNameString?Yes
ageInt?Yes
genderiOS ShopliveCommonUserGender? / Android ShopLiveCommonUserGender? / Flutter ShopLiveCommonUserGender?(MALE · FEMALE · NEUTRAL)YesSame values (m/f/n). See the callout below
userScoreInt? / Flutter int?Yes-100 to 100 is a server rule (not checked by the SDK)
customiOS [String: Any]? / Android Map<String, Any?>?(default empty map) / Flutter Map<String, dynamic>?YesUp to 1 KB is a server rule (not checked by the SDK)
Flutter An empty gender is sent differently per platform In Flutter, if you leave gender empty, Android sends NEUTRAL. iOS sends nothing.
Use the iOS gender enum case exactly, typo and all On iOS, it's ShopliveCommonUserGender (lowercase live), and the case is .male("m") · .female("f") · .netural("n"). netural is a typo for neutral, but that's really the case name in the source code. If you write the example with the "corrected" spelling, it won't compile. On Android, it's ShopLiveCommonUserGender (capital-L Live), and .MALE("m") · .FEMALE("f") · .NEUTRAL("n") — the spelling is correct there.

The custom field

Mission 10's ShopLiveEvent.sendConversionEvent sends a request body to the server that has one more field called custom. It's easy to confuse this with the app code's ShopLiveConversionData.custom shares its name and is easy to confuse, but what actually goes into this slot differs by platform. Verified directly against the SDK source.

PlatformWhat actually ends up in the server request's custom fieldfile:line
iOSOnly the ShopLiveConversionData.custom field only — exactly what the caller passed passed in by the caller (data.custom?.toJSONString_SL())ShopLiveEvent.swift:24
AndroidShopLiveConversionData object, in its entirety (type, products, orderId, referrer and custom, all of it) serialised to a JSON string (Gson().toJson(data))ShopLiveEvent.kt:22
Same name, different contents On iOS, only the caller's custom map goes into the server custom field, but on Android, the entire ShopLiveConversionData used in the call (including the product list, order number, etc.) is packed whole as a JSON string. When you analyze this field in server logs or data insights, you need to write your parsing logic differently per platform.
Reference

Event handlers

Everything that happens inside the player reaches your app as an event. How you receive them differs by platform: on iOS you implement ShopLiveSDKDelegate, on Android you extend ShopLiveHandler, and on Flutter you subscribe to a Stream.

For each event this section states when it fires, what arrives with it, what your app should do, followed by the per-platform signature. Registration and working code live in the missions.

Events that exist on only one platform say so. That is not an omission; it means we checked and it really is absent.

The only one you must implement is handleNavigation Everything else is optional. On iOS only handleNavigation is required and the rest are @objc optional; on Android only handleNavigation is abstract and the rest are open. If you leave it as

The older iOS docs present several methods as required, but they disagree with the SDK source in six or more places. This document follows the SDK source.

Product or banner link tapped

WHENA viewer closes the a product or banner that has a detail URL registered, or a LINK-type coupon.
YOU GET
FieldTypeDescription
urliOS URL · Android·Flutter StringThe detail page address registered on that product, banner or coupon

You have to tell product, banner and coupon apart from the URL. Agreeing on a link format up front makes branching easier.

TO DORead the URL and send the user to the matching screen in your app. This is the only required implementation across all three platforms (on iOS it is not optional; on Android it is abstract). Example: Mission 6.
handleNavigation(with url: URL)
handleNavigation(context, url: String)
handleNavigation → {url}

Coupon download requested

WHENA viewer taps a Download-type coupon button. The player does not issue the coupon itself; it hands the request to your app.
YOU GET
FieldTypeDescription
couponIdStringThe coupon identifier you set in the admin
Result callbackiOS ShopLiveCouponResult
Android ShopLiveHandlerCallback
The object you report the result back through. What to pass is listed under Replying with the result
TO DOAsk your backend to issue the coupon, then always report the result through that callback. If you never reply, the player's loading indicator never clears. How to reply is in Replying with the coupon or custom action result below; for an example see Mission 5.

iOS Two older overloads with different argument shapes are deprecated. The signature below is the current one.

handleDownloadCoupon(with couponId: String, result: (ShopLiveCouponResult) -> Void)
handleDownloadCoupon(context, couponId: String, callback: ShopLiveHandlerCallback)
handleDownloadCoupon → {couponId}

Custom action popup tapped

WHENA viewer taps a popup you built as User define in the admin. Use it when you want to hand an arbitrary action, not a coupon, to your app.
YOU GET
FieldTypeDescription
idStringPopup identifier. Pass it back unchanged when you reply
typeStringThe popup kind as a string. The SDK passes on the value the player web sent, as is. Per older docs: COUPON · BANNER · NOTICE (Needs verification — web player contract). This is usually what you branch on
payloadiOS Any? · Android JSONObject
Flutter String
The value you entered in the admin
Result callbackiOS ShopLiveCustomActionResult
Android ShopLiveHandlerCallback
The object you report the result back through. See Replying with the result

payload passes your admin input through unchanged, so it has no fixed schema. Print it once before you parse it.

TO DOtype: branch on it, handle the action, then report the result. The reply works exactly like the coupon case. Example: User define in Mission 5.

iOS Two older overloads, and on Android payload: String the version taking a String payload, are deprecated.

handleCustomAction(with id: String, type: String, payload: Any?, result: (ShopLiveCustomActionResult) -> Void)
handleCustomAction(context, id: String, type: String, payload: JSONObject, callback: ShopLiveHandlerCallback)
handleCustomAction → {id, type, payload: String}

Broadcast status change

WHENThe broadcast starts or ends. It fires every time the status changes while someone is watching.
YOU GET
FieldTypeDescription
campaignStatusStringREADY (before the broadcast), ONAIR (live), CLOSED (ended)

The values are explained in more detail under Campaign in the Data field dictionary.

TO DOUse it to clean up your own screen when the broadcast ends, or to show a different banner before and during the broadcast.
handleChangeCampaignStatus(status: String)
onChangeCampaignStatus(context, campaignStatus: String)
changeCampaignStatus → {campaignStatus}

Broadcast info

WHENRight after the player fetches campaign information from the server, once, before playback starts.
YOU GET
FieldTypeDescription
campaignInfoiOS [String: Any] · Android JSONObject
Flutter Map
The campaign metadata bundle the server sent

These are not fields the SDK defines; the server response is passed through as is, so the shape varies with your campaign settings.

TO DODon't assume any field is always present. Print the real payload once, then pull out only what you need.
handleCampaignInfo(campaignInfo: [String: Any])
onCampaignInfo(campaignInfo: JSONObject)
campaignInfo → {campaignInfo: Map}

Player screen created and destroyed

WHENThe player screen moves created and destroyed. This is the lifetime of your app's screen, not the broadcast status.
YOU GET
FieldTypeDescription
status (iOS·Flutter)
playerLifecycle (Android)
iOS·Flutter String
Android PlayerLifecycle
See the value table below
isPipModeBoolean (Android only) true when in PIP, false
TO DOUse it to sync your own app state as the player opens and closes, for example bringing your bottom tab bar back when it closes.
ValueMeaningiOSAndroid
CREATEDThe player screen was createdYesYes
CLOSINGClosingNeverYes
DESTROYEDFully closedYesYes
Flutter you cannot tell "closing" apart Flutter's ShopLiveChangedPlayerStatus.fromJson drops any string that isn't 'CREATED' turns everything that is not destroyed into destroyed. So the CLOSING that Android sends reads as "already closed" on Flutter. If you need to handle closing and closed separately, do not rely on this value.

iOS never sends CLOSING at all. We checked the SDK source directly: this callback is only invoked in two places, with "CREATED" and "DESTROYED" and nothing else. Android has three stages; iOS and Flutter have two.

handleChangedPlayerStatus(status: String)
onChangedPlayerStatus(isPipMode: Boolean, playerLifecycle: PlayerLifecycle)
changedPlayerStatus

Viewer changed their display name

WHENA viewer changes their display name in the chat panel.
YOU GET
FieldTypeDescription
userIdStringViewer identifier, exactly as you passed it in
userNameStringThe display name the viewer just set

On both iOS and Android the two values arrive inside a single bundle (Map · JSONObject).

TO DOIf you need to keep your own member record in sync, take it here and push it to your backend. Do nothing and the new name still shows inside the player. For user setup see Mission 3.
onSetUserName(_ payload: [String: Any])
onSetUserName(jsonObject: JSONObject)
userInfo → {userInfo: Map}

Error

WHENPlayback, entry or authentication fails. A wrong campaign key, an expired token, a network failure all arrive here.
YOU GET
FieldTypeDescription
codeStringError code. The full list is in Error codes
messageStringError message. Use it for logging rather than showing it to viewers as is

Android the current API wraps both values in a ShopLiveCommonError object. Read them with error.code · error.message.

TO DOBranch on the code. In particular -412 (session expired) recovers without losing the screen: issue a fresh token and call play again. The same number can mean different things on different platforms, so check the table. Codes in the 9100s are status notices, so do not close the player.

Android onError(context, code, message) is deprecated. Use the one below that takes ShopLiveCommonError.

handleError(code: String, message: String)
onError(context, error: ShopLiveCommonError)
error → {code, message}

Something tapped inside the player (command)

WHENA viewer taps something inside the player. Product taps, cart taps and brand likes all arrive here.
YOU GET
FieldTypeDescription
commandStringWhat was tapped. For example CLICK_PRODUCT_DETAIL · CLICK_PRODUCT_CART
dataiOS [String: Any]? · Android JSONObject
Flutter Map
Information about what was tapped

data passes through the product data you registered on the campaign, so the fields differ per service. Don't assume a fixed schema.

TO DOcommand Branch on the command name. Other commands such as coupon and login requests come through the same callback, so branching is mandatory. Example: Mission 6; for likes see Mission 11.

iOS handleReceivedCommand(_:with:) is deprecated. Both are @objc optional, so implementing the wrong one still compiles. If nothing arrives, check the signature first.

handleReceivedCommand(_ command: String, data: [String: Any]?)
onReceivedCommand(context, command: String, data: JSONObject)
receivedCommand → {command, data: Map}

iOS only Screen transition tracking

WHENThe player screen moves between fullscreen, PIP and closed. It fires both just before and just after each transition.
YOU GET
FieldTypeDescription
commandStringTransition name, one of the 6 current or 4 legacy names below
payloadAny?lastStyle · currentStyle · isPreview. viewDidDisAppear alone also carries viewHiddenActionType (String; the value is a reason from Screen closed). For the legacy willShopLiveOff · didShopLiveOff, the payload is style (Int rawValue)

Current 6(ShopLiveViewTrackEvent): viewWillDisAppear · viewDidDisAppear · pipWillAppear · pipDidAppear · fullScreenWillAppear · fullScreenDidAppear.

Legacy 4: willShopLiveOn · didShopLiveOn · willShopLiveOff · didShopLiveOff. ShopLiveViewTrackEvent no longer lists these old names, but they still arrive in 1.9.0. Use the current 6 in new code.

TO DOUse it to attach and detach your own UI in step with the transition. This is a completely different event from the command callback above, so don't mix them up. That one is a viewer tap; this one is a screen transition.

Android Flutter have no matching event. On Android, onChangedPlayerStatus's isPipMode tells you something similar, but without the before/after distinction.

handleCommand(_ command: String, with payload: Any?)

iOS only Dragging the PIP window

WHENContinuously, while a viewer drags the in-app PIP window with their finger.
YOU GET
FieldTypeDescription
stateUIGestureRecognizer.Statebegan · changed · ended and so on: how far the gesture has progressed
positionCGPointScreen coordinates of the PIP window's centre
TO DOUse it only when you need the coordinates, for example to move your own UI out of the PIP window's way. Most integrations never implement it.

Android Flutter have no matching event. This one deals with screen coordinates and is specific to iOS.

playerPanGesture(state: UIGestureRecognizer.State, position: CGPoint)

iOS only Preview window tapped

WHENA viewer taps the preview window.
YOU GETNothing. Only the fact that it was tapped.
TO DOMission 9's preview(data:completion:) overlaps with this in purpose. The fullscreen transition is usually handled in that completion, so keep this event for things like extra logging.

This event did not appear in the older docs. Android Flutter have no equivalent.

handlePreviewTouchGesture()

Activity log

WHENEvery time something is shown or tapped inside the player. Everything streams out for analytics.
YOU GET
FieldTypeDescription
nameStringWhat happened
featureStringclick · show · action, one of three
campaignKeyString? · Flutter String (never null; empty string when missing)Which broadcast it happened in
payloadMap · Flutter Map<String, dynamic> (never null; empty map when missing)Extra information. The shape differs per name
TO DOForward it to your own analytics. There is a lot of it, so filter down to the name values you actually need. The demo app, for example, skips player_active_seconds.

log Use onEvent instead. On both iOS and Android the older log family (two on iOS, one on Android) is deprecated. Flutter is the exception: its stream is named log and nothing else. The Flutter log stream receives the native legacy path (iOS log(name:feature:campaign:payload:), Android onReceivedCommand's EVENT_LOG). The current onEvent is not wired up.

onEvent(name: String, feature: ShopLiveLog.Feature, campaign: String, payload: [String: Any])
onEvent(context, data: ShopLiveLog.Data)
log → {name, feature, campaignKey, payload: Map}

Share button tapped

WHENA viewer taps the share button in the player's top right corner.
YOU GET
FieldTypeDescription
urlString?The link to share. You set its format in advance with setShareScheme
campaignShopLivePlayerShareCampaign?campaignKey · title · description · thumbnail
TO DOOnly implement this if you want a share sheet styled to your own app. Leave it alone and the SDK shows the OS share sheet. Example: Mission 7.

Where you receive it differs by platform. iOS is ShopLiveSDKDelegate it is not on ShopLivePlayerShareDelegate but on a separate protocol, Android is ShopLiveHandler it sits inside Flutter has no matching stream, so only the default share sheet is available.

ShopLivePlayerShareDelegate.handleShare(data: ShopLivePlayerShareData)
handleShare(context, data: ShopLivePlayerShareData)

Android only Viewer closed the player

WHENA viewer closes the fullscreen player themselves.
YOU GET
FieldTypeDescription
isPipModeBoolean true if it was in PIP when closed, false
TO DOOn Android, you didn't confuse this is the callback you need to catch a fullscreen exit. The Screen closed(onClose) below only covers PIP windows closing and excludes the fullscreen exit.

This event did not appear in the older docs. iOS is setCloseHandler already covers the fullscreen exit, so there is no separate callback. Flutter has no equivalent.

onUserCloseAction(isPipMode: Boolean)

Screen closed (with reason)

WHENA PIP window or preview window closes. It fires exactly once per screen instance. Switching between PIP and fullscreen is a transition, not a close, so it does not fire.
YOU GET
FieldTypeDescription
typeShopLivePresentTypeWhether the closed screen was in-app PIP or OS PIP. The iOS closure receives only reason; read the screen kind from reason.presentType
reasonShopLiveViewHiddenActionTypeWhy it closed. The values are in the table below
TO DOUse it when you want to react differently depending on the reason, for example showing a different screen when the broadcast ended versus when the viewer closed it themselves. Related: Mission 9.

iOS This is the only event that lives outside ShopLiveSDKDelegate. You receive it through a separate API, ShopLive.setCloseHandler, the same way as handleShare. To receive only preview closes, use ShopLive.setPreviewCloseHandler(_:). When a preview closes, it is called together with setCloseHandler.

The three platforms cover different kinds of "closed"

They look like the same event, but each catches a different screen. This is an easy thing to miss when you are trying to catch a fullscreen exit.

PlatformWhat it reportsWhat it misses
iOS setCloseHandlerEvery screen close, including fullscreen exitnone
Android onCloseWhen an in-app PIP or OS PIP window closes Fullscreen player exit. Use onUserCloseAction for that
Flutter previewCloseWhen the preview window closesEvery other kind of close

Reason values: 8 names are shared by both platforms, only spelled differently (iOS onBtnTapped ↔ Android ON_BTN_TAPPED). Android has 1 more (ON_UNKNOWN).

Reason for closingiOSAndroidDoes Android actually send it?
Swiped out of the screenonSwipeOutON_SWIPE_OUTYes
Close button tappedonBtnTappedON_BTN_TAPPEDYes
Ordinary closeonCloseON_CLOSEYes
Replaced by another screenonReplace (during preview, calling play() · preview() again)ON_REPLACEYes
Closed for an unknown reasonnoneON_UNKNOWNYes
Closed due to an erroronErrorON_ERRORNot yet
Restoring from PIPonRestoringPipON_RESTORING_PIPNot yet
Closed by link navigation handlingonNavigationHandleCloseON_NAVIGATION_HANDLE_CLOSENot yet
Broadcast endedonBroadcastEndedON_BROADCAST_ENDEDYes — only for preview popups shown with showPreviewPopup. A ShopLivePreview placed in a layout does not close; it restarts after 30 seconds

"Not yet" means the value is reserved. Per the comments in the Android source it is not emitted from any screen yet; the cases exist only to mirror the iOS value set. Branching on them today will never match.

Flutter previewClose stream sends ShopLivePreviewClose{campaignKey, reason}. reason maps to the native reasons as follows.

Flutter reasonNative reason (iOS / Android)
closeButtononBtnTapped / ON_BTN_TAPPED
swipeOutonSwipeOut / ON_SWIPE_OUT
broadcastEndedonBroadcastEnded / ON_BROADCAST_ENDED
programmaticThe app calls hidePreview()
unknownAny other reason
Android attaching a handler to a preview instance stops the global onClose from firing

ShopLivePreview.setHandler or ShopLivePreviewData.setHandler, only that handler is called and the global onClose is not. This rule applies only to popups shown with ShopLive.showPreviewPopup.

The inline form, where you place ShopLivePreview directly in your layout XML, is not covered by this callback at all. It uses OnCloseActionListener from the section above instead.

ShopLive.setCloseHandler(_ handler: (ShopLiveViewHiddenActionType) -> Void)
ShopLive.setPreviewCloseHandler(_ handler: (ShopLiveViewHiddenActionType) -> Void)
onClose(type: ShopLivePresentType, reason: ShopLiveViewHiddenActionType)
previewClose → ShopLivePreviewClose {campaignKey, reason}

Preview playback state

WHENEvery time the preview window's playback state changes.
YOU GET
FieldTypeDescription
Playback stateiOS enum · Android data classready (ready), playing (playing), paused (paused), buffering (buffering) and failed (failed)
TO DOShow a placeholder until the preview actually plays, or hide the preview when it is failed. Related: Mission 9.

iOS and Android are effectively identical in name and payload. The only difference is language convention: lowercase enum cases on iOS, PascalCase data classes on Android. iOS receives them through a separate protocol. Flutter has no equivalent. Searching the whole SDK turns up no stream that does the same job.

Android setOnPlaybackStateChanged is a different thing Preview components The listener in that list only watches ExoPlayer's playback state and is something else entirely. The names are similar and easy to swap by mistake, so be careful.
ShopLivePlayerPreviewDelegate.handlePlaybackEvent(_ event: ShopLivePreviewPlaybackEvent)
ShopLivePreview.setOnPlaybackEventListener(OnPlaybackEventListener)

Replying with the coupon or custom action result

WHENRight after your app has handled the Coupon download requested·Custom action popup tapped above. This is the only one that goes from your app to the player.
What you send
ItemNameDescription
Success flagiOS success: Bool
Android isDownloadSuccess/isSuccess
Flutter success
Whether your handling succeeded
IdentifiercouponId or idWhich coupon or popup this result is for. On Android the SDK fills it in, so you omit it
MessagemessageThe text to show the viewer
Popup handlingiOS status · Android·Flutter popupStatus/couponStatusSHOW · HIDE · KEEP
Notice stylealertTypeALERT · TOAST
TO DOAlways reply. Without a reply the player's loading indicator never clears and the viewer stares at a frozen screen. The player shows a different notice depending on success or failure.

How you call it differs by platform. iOS Android fill in a result object the SDK handed you along with the event. Flutter alone calls a separate method to send the result. The Flutter side is not a Stream, so do not subscribe to it. This is a confirmed design difference, not an open question.

// Coupon result. Pass this to the result parameter of handleDownloadCoupon
ShopLiveCouponResult(couponId: String,
                     success: Bool,
                     message: String?,
                     status: ShopLiveResultStatus,      // SHOW / HIDE / KEEP
                     alertType: ShopLiveResultAlertType) // ALERT / TOAST

// Custom action result. Pass this to the result parameter of handleCustomAction
ShopLiveCustomActionResult(id: String,
                           success: Bool,
                           message: String?,
                           status: ShopLiveResultStatus,
                           alertType: ShopLiveResultAlertType)
// Both go into the callback object the SDK handed you
callback.couponResult(
    isDownloadSuccess = true,
    message = successMessage,
    couponStatus = ShopLive.CouponPopupStatus.HIDE,          // SHOW / HIDE / KEEP
    alertType = ShopLive.CouponPopupResultAlertType.TOAST    // ALERT / TOAST
)

callback.customActionResult(
    isSuccess = true,
    message = successMessage,
    couponStatus = ShopLive.CouponPopupStatus.HIDE,
    alertType = ShopLive.CouponPopupResultAlertType.ALERT
)
// Flutter alone sends the result through a separate method, not a stream
await ShopLivePlayer().sendDownloadCouponResult(
  couponId: couponId,
  success: true,
  message: successMessage,
  popupStatus: "HIDE",   // SHOW / HIDE / KEEP
  alertType: "TOAST",    // ALERT / TOAST
);

await ShopLivePlayer().sendCustomActionResult(
  id: id,
  success: true,
  message: successMessage,
  popupStatus: "HIDE",
  alertType: "ALERT",
);
Popup handling valueWhat the player does
SHOWKeeps the popup open
HIDECloses the popup. This is the usual choice on a successful download
KEEPLeaves the current state as it is
Notice style valueWhat the player does
ALERTShows the message in an alert dialog with a confirm button
TOASTShows the message as a toast that fades away
Preview · Reference

API · Options

Every option that fine-tunes player behaviour, grouped by what it does. How to use them is covered in the missions; here you see what exists. Entries whose comments carry a mission number have a usage example in that mission.

The three platforms do not offer the same coverage. In particular, Flutter is missing a good number of the options iOS and Android have. Each group states up front what is missing on which platform, so check that first.

Don't use the old paths in new code ShopLive.configure · ShopLive.authToken · ShopLive.user · ShopLive.setUser(ShopLiveUser) are legacy. They are kept in the lists below so you can read older code; when writing new code, always use the ShopLiveCommon.* path.

Users and authentication

Tell the SDK who the viewer is and, for logged-in members, hand over their authentication. For the actual integration steps see Mission 3.

Flutter is ShopLiveCommon() does the same job through its instance. See Mission 3 for an example.

// Legacy — user info. Replacement: ShopLiveCommon.setUser(user:accessKey:). The constructor parameter label is userName: (the original name: doesn't compile) · Mission 3
ShopLive.user: ShopLiveCommonUser?

// Legacy — user info JWT (secure auth). Replacement: ShopLiveCommon.setAuthToken(authToken:) · Mission 3
ShopLive.authToken: String?

// Legacy (scheduled for removal in v2) — sets the AccessKey with a single with accessKey: String. Replacement: ShopLiveCommon.setAccessKey(accessKey:)
ShopLive.configure

// Gets the JWT set with setAuthToken. If none, the JWT generated by setUser · Mission 3
ShopLiveCommon.getAuthToken() -> String?
// Set user info (accessKey keeps whatever value is already set). To specify accessKey together, see ShopLiveCommon.setUser(accessKey, user) · Mission 3
ShopLive.setUser(user: ShopLiveCommonUser?)

// Legacy — ShopLiveUser type is no longer used. Replacement: the ShopLiveCommonUser version above
ShopLive.setUser(user: ShopLiveUser)

// Set the user info JWT (secure auth) · Mission 3
ShopLive.setAuthToken(authToken: String)

// Read the setting above · Mission 3
ShopLive.getAuthToken(): String?

// Legacy — set the AccessKey. Replacement: ShopLive.setAccessKey(accessKey:)
ShopLive.configure(accessKey: String)

PIP

Move between fullscreen and a small window, and decide that window's size, position and shape. The behaviour is explained in Mission 8.

// Whether to allow entering in-app PIP/OS PIP at all (turning it off also disables auto-PIP when leaving the player) · Default true · Mission 8
setEnabledPictureInPictureMode(isEnabled: Bool)

// Fine-tunes in-app PIP size, position, and close button (show the close button with the config.useCloseButton property, which replaces the legacy useCloseButton(_:); close button look = closeButtonConfig). Takes priority over the older individual setters · Mission 8
setInAppPipConfiguration(config: ShopLiveInAppPipConfiguration)

// Calculate the actual render size given a PIP setting and aspect ratio
getPreviewSize(inAppPipConfiguration: ShopLiveInAppPipConfiguration, videoRatio: CGSize) -> CGSize

// Attach a custom view over the Preview screen (you must turn off the close button separately)
addSubViewToPreview(subView: UIView)

// Switch from full screen to in-app PIP · Default scale 0.4 · Mission 8
startPictureInPicture(with position: ShopLive.PipPosition, scale: CGFloat) / startPictureInPicture()

// Switch from PIP to full screen · Mission 8
stopPictureInPicture()

// Whether to keep the last playback state (PIP/full screen) when returning to the app after leaving via OS PIP, or always switch back to full screen · Default false (always full screen) · Mission 8
setKeepWindowStyleOnReturnFromOsPip(_ keep: Bool)

// Read the setting above · Mission 8
isKeepWindowStyleOnReturnFromOsPip() -> Bool

// Whether to separately allow only OS PIP (unrelated to the in-app PIP setting) · Default true · Mission 8
setEnabledOSPictureInPictureMode(isEnabled: Bool)

// Top/bottom/left/right padding for the area in-app PIP can move within. Returns false and applies the default if the value is invalid · Default top/left/right/bottom 20 · Mission 8
setPictureInPicturePadding(padding: UIEdgeInsets) -> Bool

// Restrict the area in-app PIP can move within itself, as an offset. Returns false if the value is invalid · Mission 8
setPictureInPictureFloatingOffset(offset: UIEdgeInsets) -> Bool

// Legacy — whether to show the close button in Preview/PIP mode. Replacement: setInAppPipConfiguration(config:)'s useCloseButton property
useCloseButton(_ use: Bool)
// Whether to automatically switch to PIP when leaving the player · Mission 8
ShopLive.setEnabledPictureInPictureMode(isEnabled: Boolean)

// The default aspect ratio when the PIP screen starts (RATIO_1X1 / 1X2 / 2X3 / 3X4 / 9X16) · Mission 8
ShopLive.setPIPRatio(pipRatio: ShopLivePIPRatio)

// Whether to switch to PIP instead of closing when leaving the screen via the back button · Mission 8
ShopLive.setEnterPipModeOnBackPressed(isEnterPipMode: Boolean)

// Switch from full screen to PIP. Legacy: the parameterless startPictureInPicture() is deprecated (this signature calls shouldDelayForOverlayPermissionFlow = false) · Mission 8
ShopLive.startPictureInPicture(shouldDelayForOverlayPermissionFlow: Boolean)

// Switch from PIP to full screen · Mission 8
ShopLive.stopPictureInPicture()
// Whether to switch to PIP instead of closing when leaving the screen via the back button · Mission 8
ShopLivePlayer().setEnterPipModeOnBackPressed(isEnterPipMode: bool)

// Switch full screen ↔ PIP · Mission 8
ShopLivePlayer().startPictureInPicture() / stopPictureInPicture()

// Whether to show the close button in Preview/PIP mode
ShopLivePlayer().useCloseButton(canUse: bool)

Link navigation and sharing

Decide what happens to the player when a product or banner link is tapped, and what format share links take. Mission 6·Mission 7.

// Set the scheme/URL to pass to the share popup. Passing shareDelegate implements your own custom share UI instead of the system share sheet · Mission 7
setShareScheme(_ scheme: String?, shareDelegate: ShopLivePlayerShareDelegate?)

// Intercept only the URL as a closure when a product/banner is selected — registering it means the handleNavigation delegate isn't called (iOS only) · Mission 6
hookNavigation(navigation: @escaping (URL) -> Void)

// Set the player's next action after selecting a product/banner link (PIP/KEEP/CLOSE) · Default PIP · Mission 11
setNextActionOnHandleNavigation(actionType: ActionType)

// Read the setting above · Mission 11
getNextActionTypeOnHandleNavigation() -> ActionType
// Set the scheme/URL to pass to the share popup · Mission 7
ShopLive.setShareScheme(shareSchemeUrl: String?)

// Set the player's next action after selecting a product/banner link (PIP/KEEP/CLOSE) · Mission 11
ShopLive.setNextActionOnHandleNavigation(type: ActionType)
// Set the scheme/URL to pass when sharing · Mission 7
ShopLivePlayer().setShareScheme(shareSchemeUrl: String)

// Set the next action after selecting a product/banner link (show/hide/keep) — passed to native as a string, but the Android receiver reads it as an integer, so it's actually ignored (see Mission 11) · Mission 11
ShopLivePlayer().setNextActionOnHandleNavigation(type: ShopLiveActionType)

Screen and UI

Match the player's look to your app: loading indicator, chat font, status bar, video aspect ratio and so on. Mission 11.

Flutter can only adjust the video aspect ratio. There is no matching API for the other UI options.

iOS ShopLiveCommon.setFontFamily does not apply to the live player (it is for shortform · editor), so it is left out of the list below.

// Set the chat input field/send button font · Default System font · Mission 4
setChatViewFont(inputBoxFont: UIFont?, sendButtonFont: UIFont?)

// Show video loading progress as an image-sequence animation
setLoadingAnimation(images: [UIImage])

// Force-attach a view that ShopLive's policy otherwise wouldn't allow adding via ordinary addSubview
playerWindow?.forceAddSubView(_:)

// Hide/show the status bar during full-screen playback (always shown automatically in InAppPip) · Default true (shown)
setVisibleStatusBar(isVisible: Bool)

// Set the loading indicator color · Mission 11
indicatorColor: UIColor

// Set how to maintain the video aspect ratio
setResizeMode(mode: ShopLiveResizeMode)
// Set the loading progress bar color · Mission 11
ShopLive.setLoadingProgressColor(@ColorInt colorInt: Int)

// Set the loading progress bar as an image animation (pass 0 to clear it) · Mission 11
ShopLive.setLoadingAnimation(@DrawableRes drawableRes: Int)

// Set the chat input field/send button font, shared · Mission 4
ShopLive.setChatViewTypeface(typeface: Typeface?)

// Hide/show the screen's status bar · Default false (hidden)
ShopLive.setVisibleStatusBar(isVisible: Boolean)

// Changes the status bar and navigation bar colors. Call before play. ShopLivePlayerSystemBarColorData(statusBarColor · navigationBarColor default black DEFAULT_COLOR, isTaskBarOpened default false)
ShopLive.setSystemBarColor(data: ShopLivePlayerSystemBarColorData)

// Resets the color setting above to the default. Call before play
ShopLive.clearSystemBarColor()

// Show/dismiss a custom DialogFragment over the player screen
ShopLive.showDialogFragment(dialogFragment: DialogFragment?) / closeDialogFragment()

// Whether to allow screenshots of the broadcast screen
ShopLive.setPlayerScreenCaptureEnabled(isEnabled: Boolean)

// Set how to maintain the video aspect ratio · Default CENTER_CROP
ShopLive.setResizeMode(resizeMode: ShopLiveResizeMode)
// Set how to maintain the video aspect ratio
ShopLivePlayer().setResizeMode(mode: ShopLiveResizeMode)

Sound and interruptions

Set the mute state and decide how playback continues when a call comes in or headphones are unplugged. Mission 11.

Flutter can only adjust muting and coexistence with other apps' audio. There are no call or headphone interruption options.

// Set whether to auto-resume once the video returns after a call ends · Default false (no auto-resume) · Mission 11
setAutoResumeVideoOnCallEnded(_ autoResume: Bool)

// Read the setting above · Mission 11
isAutoResumeVideoOnCallEnded() -> Bool

// Whether to keep playing when the earphones (headset) disconnect (keepPlay, required argument), and if so, whether to mute (isMute). If not called, the SDK's default behavior is to stop · Default isMute defaults to false · Mission 11
setKeepPlayVideoOnHeadphoneUnplugged(_ keepPlay: Bool, isMute: Bool = false)

// Read the setting above · Mission 11
isKeepPlayVideoOnHeadPhoneUnplugged() -> Bool

// Whether to mute when the player starts · Default false (sound on) · Mission 11
setMuteWhenPlayStart(_ mute: Bool)

// Whether to play alongside other apps' audio, or pause when other audio plays · Default true (play alongside)
setMixWithOthers(isMixAudio: Bool)

// Directly specify AVAudioSession.Category (iOS-only audio session control)
setAudioSessionCategory(category: AVAudioSession.Category)
// Whether to keep playing when the earphones (headset) disconnect (isKeep), and if so, whether to mute (isMute). Both arguments are required; there is no one-argument form. The disconnect is detected via ACTION_AUDIO_BECOMING_NOISY, so no permission is needed (Android 12+ devices: needs verification) · Mission 11
ShopLive.setKeepPlayVideoOnHeadphoneUnplugged(isKeep: Boolean, isMute: Boolean)

// Whether to auto-resume the video after a call ends. Requires the READ_PHONE_STATE permission · Mission 11
ShopLive.setAutoResumeVideoOnCallEnded(isAutoResume: Boolean)

// Whether to mute when the player starts · Default false (sound on) · Mission 11
ShopLive.setMuteWhenPlayStart(isMuted: Boolean)

// Receive and handle the event yourself when another app takes audio focus (a call, etc.). OnAudioFocusListener has two methods: onGain() · onLoss()
ShopLive.setSoundFocusHandling(listener: OnAudioFocusListener?)

// Whether to play alongside other apps' audio, or pause when other audio plays · Default true (play alongside)
ShopLive.setMixWithOthers(isMixAudio: Boolean)
// Whether to mute when the player starts · Mission 11
ShopLivePlayer().setMuteWhenPlayStart(isMute: bool)

// Whether to play alongside other apps' audio, or pause when other audio plays
ShopLivePlayer().setMixWithOthers(isMixAudio: bool)

Passing data

Send values along to the player, or record the acquisition source in your analytics. Mission 2·Mission 10.

// Send a predefined command/payload to the server (e.g. showing a toast over the player)
// For a toast: command "SHOW_LAYER_TOAST", payload keys message (required) · duration (ms) · position (TOP/BOTTOM/CENTER) — based on older docs · needs verification
sendCommandMessage(command: String, payload: [String:Any]?)

// Whether to use local storage
setUsingLocalStorage(_ use: Bool)

// Send the app version (the SDK reads and sends this to the server automatically even without calling it)
setAppVersion(_ appVersion: String)

// Add/remove custom data (key-value) before launching the player
addParameter(key: String, value: String) / removeParameter(key: String)

// Pass UTM parameters along to data insights
setUtmSource(utmSource: String?) / setUtmMedium(utmMedium: String?) / setUtmCampaign(utmCampaign: String?) / setUtmContent(utmContent: String?)
// Send a predefined command/payload to the server
// For a toast: command "SHOW_LAYER_TOAST", payload keys message (required) · duration (ms) · position (TOP/BOTTOM/CENTER) — based on older docs · needs verification
ShopLive.sendCommandMessage(command: String?, payload: Map<String, Any?>)

// Send the app version (the SDK reads and sends this to the server automatically even without calling it)
ShopLive.setAppVersion(appVersion: String)

// Pass UTM parameters along to data insights
ShopLive.setUtmSource / setUtmMedium / setUtmCampaign / setUtmContent(String?)

// Add/remove custom data (key-value) before launching the player
ShopLive.addParameter(key: String, value: String?) / removeParameter(key: String)

// Use a specified URL instead of Shoplive's default Web Landing page (requires prior coordination)
ShopLive.setEndpoint(endpoint: String?)
// Use the specified URL as the Web Landing page (requires prior coordination)
ShopLivePlayer().setEndpoint(endpoint: String?)

// Add/remove custom data (key-value) before launching the player
ShopLivePlayer().addParameter(key: String, value: String?) / removeParameter(key: String)

// Send a predefined command/payload to the server
ShopLivePlayer().sendCommandMessage(command: String, payload: Map<String, dynamic>)

// Pass UTM parameters along to data insights
ShopLiveCommon().setUtmSource / setUtmMedium / setUtmCampaign / setUtmContent(String?)

State queries and closing

Ask what state the player is in right now, and close it if you need to.

Flutter can only close the player; it has no API for querying state.

// Read the current player style (fullScreen / pip / unknown)
style: ShopLive.PresentationStyle

// Read the current player mode (play / preview / none)
playerMode: ShopLive.PlayerMode

// Read the aspect ratio of the currently playing broadcast (portrait / landscape / unknown)
orientationMode: ShopLive.VideoOrientation

// The UIWindow instance the player is displayed in
playerWindow: ShopliveWindow?

// The UIViewController instance the player is displayed in (used when presenting an Alert, etc.)
viewController: ShopLiveViewController?

// Checks whether joining the campaign succeeded. true once ON_SUCCESS_CAMPAIGN_JOIN has arrived through handleReceivedCommand
isSuccessCampaignJoin() -> Bool
// End the currently-watched broadcast
ShopLive.close()

// Whether to also end the broadcast when the root Activity finishes. If false, you must call close() yourself when the app exits · Default true (end together)
ShopLive.setAutoCloseWhenAppDestroyed(isAutoClosed: Boolean)

// Read the current screen state (None / InAppPIP / PIP / Full)
ShopLive.getStatus(): Status

// Whether the preview popup is currently shown
ShopLive.isShowingPreviewPopup(): Boolean

// Checks whether joining the campaign succeeded. true once the player has sent the ON_SUCCESS_CAMPAIGN_JOIN command
ShopLive.isSuccessCampaignJoin(): Boolean

// (Optional) Get the size of · delete the SDK cache. Both are @WorkerThread, so call them in the background. The SDK cleans up automatically when the last Activity is destroyed, so you usually do not need them
ShopLiveCommon.getCacheDirectoryBytes(context: Context): Long
ShopLiveCommon.deleteCacheFiles(ShopLiveCommon.getCacheDirectory(context)): Boolean
// End the currently-watched broadcast
ShopLivePlayer().close()

Campaign list lookup

Gets the list of campaigns created with your Access Key and the status of each. It draws no UI; your app decides how to show the list. Before calling, setAccessKey must already be done.

Flutter does not have this API in the 1.9.0 plugin.

import ShopLiveSDK
import ShopliveSDKCommon   // needed when you use the request and response types directly

let request = ShopLiveCampaignListRequest(page: 1, size: 20, statuses: [.onair, .ready], order: .descending)

// The callback arrives exactly once on the main thread
let task = ShopLive.API.getCampaigns(request) { result in
    switch result {
    case .success(let response):
        for campaign in response.campaigns {
            if case .onair = campaign.lifecycle.status {
                print(campaign.campaignKey, campaign.title ?? "")
            }
            // If showUserCount is false, do not show the viewer count
            if let metrics = campaign.metrics, metrics.showUserCount {
                print(metrics.userCount ?? 0)
            }
        }
        print(response.hasMore)   // if true, request again with page + 1
    case .failure(let error):
        print(error.codes)        // 9000 · server code · 9900 · 9901
    }
}
// Call task.cancel() when you leave the screen

// async version for iOS 13+. Cancelling the Task also cancels the request and throws CancellationError
let response = try await ShopLive.API.getCampaigns(ShopLiveCampaignListRequest(statuses: [.onair]))
val request = ShopLiveCampaignListRequest(
    page = 1,
    size = 20,
    statuses = listOf(ShopLiveCampaignFilterStatus.ONAIR, ShopLiveCampaignFilterStatus.READY),
    order = ShopLiveCampaignOrder.DESCENDING,
)

// The callback arrives exactly once on the main thread
val handle = ShopLiveCampaigns.getCampaigns(request, object : ShopLiveCampaignListListener {
    override fun onData(response: ShopLiveCampaignListResponse) {
        response.campaigns.forEach { campaign ->
            if (campaign.lifecycle.status == ShopLiveCampaignStatus.OnAir) {
                Log.d("Shoplive", campaign.campaignKey)
            }
            // If showUserCount is false, do not show the viewer count
            val metrics = campaign.metrics
            if (metrics != null && metrics.showUserCount) {
                Log.d("Shoplive", "${metrics.userCount}")
            }
        }
        // If hasMore is true, request again with page + 1
    }

    override fun onError(error: ShopLiveCommonError) {
        Log.e("Shoplive", "${error.code}")   // 9000 · server code · 9900 · 9901 · 10000
    }
})
// Call handle.cancel() when you leave the screen. After cancelling, no callback arrives
ItemiOSAndroid
CallShopLive.API.getCampaigns(_:completion:) → ShopLiveCancellable. The async version needs iOS 13+ShopLiveCampaigns.getCampaigns(request, listener) → ShopLiveCancellable. To omit the request, name the argument, as in getCampaigns(listener = ...)
Requestpage (starts at 1) · size (1–20; the SDK clamps out-of-range values) · statuses(READY · ONAIR · CLOSED; empty means all) · order (ascending · descending). Values you omit use the server defaults (1 · 10 · newest first)
Responsecampaigns (zero items is normal) · hasMore (whether there is a next page)
Campaign statuscampaign.lifecycle.status — .reserved · .ready · .onair · .closed · .unknown(String)campaign.lifecycle.status — Reserved · Ready · OnAir · Closed · Unknown(raw)
RehearsalDuring a rehearsal the status comes as Ready. Tell them apart with lifecycle.rehearsal
Metricsmetrics only arrives for onair · closed. If showUserCount is false, do not show the numbers
CallbackExactly once on the main thread, success or failure. After cancel(), it does not arrive
Error codes9000 (accessKey not set) · server code · 9900 (network) · 9901 (parsing). Read the code from error.codes9000 (accessKey not set) · server code · 9900 (network, 15-second limit) · 9901 (parsing) · 10000 (other). Read the code from error.code
Branch on the error code, not the message The SDK does not expose the server's description text; it passes a generic message instead. Use the error code to find the cause.
Preview · Reference

API · Play / Mute / Preview

The APIs that open and close a broadcast. If API · Options is about "how to dress up a player that is already open", this section is about "when to open it and when to close it". Entries whose comments carry a mission number have a usage example in that mission.

Starting and stopping playback

The basic APIs for playing and closing a broadcast. If this is your first integration, start from Mission 1.

// Start playing the broadcast video via campaignKey. Fine-tune behavior via ShopLivePlayerData's keepWindowStateOnPlayExecuted (default true, keeps the running mode), referrer, campaignHandler, brandHandler, and isEnabledVolumeKey · Mission 1
play(data: ShopLivePlayerData)

// Play a preview via campaignKey. campaignKey, referrer, campaignHandler, brandHandler, and isEnabledVolumeKey always apply, but isMuted/resolution (.LIVE/.PREVIEW, default .PREVIEW) only apply when the data argument is actually of type ShopLivePreviewData (ShopLivePlayerData passed as-is is ignored). keepWindowStateOnPlayExecuted isn't read on this path (it's play-only) · Mission 9
preview(data: ShopLivePlayerData, completion: (() -> Void)?)

// End the currently-watched broadcast
close()
// Set the AccessKey to use the Android SDK · Mission 1
ShopLive.setAccessKey(accessKey: String)

// Play the broadcast video via campaignKey. Fine-tune behavior via keepWindowStateOnPlayExecuted (default true) and referrer. intentPlay only builds and returns the Intent object (for when you want to call startActivity yourself) · Mission 1
ShopLive.play(context: Context, data: ShopLivePlayerData) / ShopLive.intentPlay(...): Intent

// Show a popup-style preview via campaignKey. Fine-tune size, margin, mute, close button, swipe-to-close, etc. via ShopLivePreviewData · Mission 9
ShopLive.showPreviewPopup(activity: Activity, data: ShopLivePreviewData)

// When using ShopLivePreviewData.useCustomAction, apply transitionAnimation to the screen transition triggered by tapping the preview
ShopLive.setPreviewTransitionAnimation(activity: Activity, view: View?)

// Hide the currently playing popup-style preview
ShopLive.hidePreviewPopup()
// Start playing the broadcast video via campaignKey · Mission 1
ShopLivePlayer().play(data: ShopLivePlayerData)

// Show a preview via campaignKey · Mission 9
ShopLivePlayer().showPreview(campaignKey: String)

// Set preview display options · Mission 9
ShopLivePlayer().setPreviewOption(data: ShopLivePlayerPreviewData)

// Hide the currently shown preview
ShopLivePlayer().hidePreview()

Muting during playback

Turn the sound of an already-playing broadcast off and on.

Flutter has no mute API for use during playback. API · Options's setMuteWhenPlayStart only whether it starts muted can be set through it.

// Mute the currently playing video
mute()

// Unmute the currently playing video
unmute()
// Mute the player
ShopLive.mute()

// Unmute the player
ShopLive.unmute()

Preview components

A component that shows a broadcast in a small window without entering fullscreen. On iOS it is ShopLivePlayerPreview, on Android ShopLivePreview. How to attach it and what it looks like are in Mission 9; this section just collects the available members.

Flutter does not have this component. Use Starting and stopping playback's showPreview·setPreviewOption·hidePreview from the section above instead.

The two components differ enough in what they offer that they are listed separately per platform.

iOS ShopLivePlayerPreview

// Initialize ShopLivePlayerPreview state (campaignKey, campaignId, resizeMode, useCloseButton, etc.) · Mission 9
preview.action(.initialize)

// Inject accessKey and campaignKey, then load the player. Replaces the global accessKey with this value · Mission 9
preview.action(.start(accessKey:campaignKey:referrer:))

// Set the mute state · Mission 9
preview.action(.setMuted(Bool))

// Inject a tracking identifier · Mission 9
preview.action(.setReferrer(String?))

// Play / stop / stop and reset · Mission 9
preview.action(.play) / .pause / .stop

// Remove from the parent view and start releasing memory · Mission 9
preview.action(.close)

// Relaunch the broadcast · Mission 9
preview.action(.retry)

// Set the corner radius · Mission 9
preview.action(.setCornerRadius(CGFloat))

// Whether to use the close button. Does nothing in 1.9.0 (close button not supported)
preview.action(.useCloseButton(Bool))

// Whether to allow unmuting via the device volume buttons · Mission 9
preview.action(.setEnabledVolumeKey(isEnabledVolumeKey: Bool))

// How to maintain the video aspect ratio · Default CENTER_CROP · Mission 9
preview.action(.setResizeMode(ShopLiveResizeMode))

// Preview resolution (.LIVE/.PREVIEW) · Default .PREVIEW · Mission 9
preview.action(.setResolutionType(ShopLivePlayerPreviewResolution))

// Closure that subscribes to events, (Result) -> (). Cases: log · handleReceivedCommand · avPlayerTimeControlStatus · avPlayerItemStatus · requestShowAlertController · didChangeCampaignStatus · onError · handleCommand · onSetUserName · handleShare · didChangeCampaignInfo · didChangeVideoDimension · handleShopLivePlayerCampaign · handleShopLivePlayerBrand · Mission 9
preview.resultHandler

// Delegate that receives playback state events (ready · playing · paused · buffering · failed) (ShopLivePlayerPreviewDelegate, weak)
preview.delegate

// Whether it is playing / whether it is muted / AVPlayerItem status / campaignKey, playback state, mute, playback time, campaign, and brand info
preview.isPlaying() / preview.isMuted() / preview.getPlayerItemStatus() / preview.getPlaybackInfo()

Android ShopLivePreview

// A preview component placed directly in a layout XML · Mission 9
<cloud.shoplive.sdk.ShopLivePreview>

// Pre-set the broadcast key/referrer · Mission 9
setCampaignKey(campaignKey: String?) / setReferrer(referrer: String?)

// Initial preview player setup · Mission 9
initializePlayer()

// Start preview playback (internally runs initializePlayer → prepare → play, in that order) · Mission 9
start() / start(accessKey:campaignKey:referrer:)

// Prepare / play / pause / stop the preview video · Mission 9
prepare() / play() / pause() / stop()

// Release preview resources · Mission 9
release()

// Relaunch the preview · Mission 9
retry()

// Toggle mute state · Mission 9
setMuted(isMuted: Boolean)

// How to maintain the video aspect ratio · Default CENTER_CROP · Mission 9
setResizeMode(resizeMode: ShopLiveResizeMode)

// Auto play/pause following the lifecycle when a LifecycleOwner is registered · Mission 9
setLifecycleObserver(owner: LifecycleOwner)

// Register listeners for preview-start, first-frame-render, click, close, aspect-ratio-change, and playback-state-change events
setOnInitializeListener / setOnRenderedFirstFrameListener / setOnClickListener / setOnCloseListener / setOnDimensionRatioListener / setOnPlaybackStateChanged

// Whether to use a close button · Mission 9
useCloseButton(use: Boolean)

// Whether to allow unmuting via the device volume buttons · Mission 9
setEnabledVolumeKey(isEnabledVolumeKey: Boolean)

// Preview resolution (LIVE/PREVIEW) · Default PREVIEW · Mission 9
setResolutionType(resolutionType: ShopLivePreviewResolutionType)

// Whether to show an image after the preview ends · Default true
setPostCampaignImageVisible(isPostCampaignImageVisible: Boolean)

// Release preview memory · Mission 9
destroy()

// Retrieve campaignKey, playback state, mute, playback time, campaign, and brand info all at once
getPlaybackInfo(): ShopLivePreviewPlaybackInfo

Android ShopLivePreviewData properties

showPreviewPopup · setPreviewOptions take these options (Mission 9). For size, margins, and radius, 0 · -1 means "not set"; it turns into the default on the right when read. There are no maxWidth · maxHeight properties.

PropertyTypeDefaultDescription
heightInt(px)0 → 180dpWindow height
widthInt(px)0 → 180dp×9/16 (about 101dp)Window width
marginTop · marginBottom · marginLeft · marginRightInt(px)-1 → 16dpDistance from the screen edge
canTransitionBooleantrueAllow the preview → player transition
canVibratedBooleantrueAllow vibration
useCloseButtonBooleanfalseShow the close button
positionShopLivePreviewPositionConfigBOTTOM_RIGHTWindow position
radiusFloat(px)-1 → 10dpCorner radius
referrerString?nullInflow channel
enabledSwipeOutBooleantrueSwipe off screen to close
isMutedBooleantrueStart muted
isEnabledVolumeKeyBooleanfalseAllow unmuting with the volume buttons
resolutionTypeShopLivePreviewResolutionTypePREVIEWResolution (LIVE · PREVIEW)
isPostCampaignImageVisibleBooleantrueShow an image after the campaign ends
useCustomActionBooleanfalseYour app handles a tap on the preview instead of the SDK's default transition
onClickListener · onInitializeListener · onRenderedFirstFrameListener · onCloseListener · onCloseActionListener · onCloseButtonClickListener · onCampaignListener · onBrandListener · onPlaybackEventListenerListenernullClick · start · first frame · close · close (with reason) · close button · campaign · brand · playback state
handlerShopLiveHandler?nullA handler only for this preview (see Screen closed)
backgroundColorInt?(ColorInt)nullBackground color
closeButtonConfigCloseButtonConfig?nullClose button look (position · size · offset · color · shadow · image)

Flutter ShopLivePlayerPreviewData

setPreviewOption takes these options. If you leave a value empty, native fills in a default that differs by platform.

FieldTypeDefaultDescription
campaignKeyString(required)Campaign key
useCloseButtonboolfalseShow the close button
enableSwipeOutbooltrueSwipe off screen to close
referrerString?nullInflow channel
pipRadiusdouble?null → 0Corner radius
pipMaxSizedouble?null → iOS 300, Android 0Long-side size of the window. Android puts the same value (dp) into both width and height; 0 seems to mean the SDK default size (Needs verification)
marginTop · marginBottom · marginLeft · marginRightdouble?null → 0Distance from the edges. Android converts dp to px
positionString?iOS TOP_LEFT, Android BOTTOM_RIGHTTOP_LEFT · TOP_RIGHT · BOTTOM_LEFT · BOTTOM_RIGHT. On both platforms, any other string falls back to BOTTOM_RIGHT
closeButtonConfigShopLiveCloseButtonConfig?nullClose button look. If you leave position empty: iOS TOP_LEFT, Android TOP_RIGHT
Flutter position — always set it If you leave it empty, the window appears top-left on iOS and bottom-right on Android. Set it explicitly to match both platforms.
Reference

Error codes

This keeps the original document's 4-way classification (connection / entering broadcast / during broadcast / other) as-is. The same code number can mean different things on different platforms. Don't infer the platform from the number alone; read the callouts below.

iOS ShopLiveErrorCases

Server-issued codes (negative) and internal SDK codes (9000+) are mixed into a single enum.

CategoryCodeConstantMeaning
Connection-102AuthenticationFailedAuthentication failed
Connection-115GuestLoginNotAllowedGuest login not allowed
Connection-200CustomAccountNotFoundService doesn't exist (accessKey error)
Connection-201CustomAccountExpiredClient's contract has expired
Connection-204InvalidSignatureJWT signature error
Connection-412ExpiredSessionSession expired
Connection / entering broadcast-500ServerErrorServer error (shared by both)
Entering broadcast-710CampaignNotFoundNo broadcast
Entering broadcast-711CampaignNotOnAirNot currently broadcasting
During broadcast-411DuplicateSessionDuplicate session
Other (internal)9000NotInitializedAccessKeyaccessKey not set
Other (internal)9001NotInitializedShareURLNo share URL
Other (internal)9002NotInitializedShortformConfigRaised when a server setting (shortform · host) is missing. Also occurs in shortform · editor
Other (internal)9003InValidURLInvalid URL. means something different from the same code on Android (see the callout below)
Other (internal)9300FailedEncodingEncoding failed
Other (internal)9310RemovedVideoVideo deleted
Status notice (not an error)9100NoVideoForPipNo video was playing when switching to PIP
Status notice (not an error)9101NoPlayableStreamUrlReady · rehearsal · live state, but no playback URL received yet
Status notice (not an error)9102LiveNotBroadcastingLive state, but the stream has not started yet
Other (internal)9501UnsupportedMediaUnsupported codec
Other (internal)9500UnsupportedOSpipModeOS PIP not supported (below iOS 14)
Other (internal)9900FailedNetworkNetwork failure
Other (internal)9901FailedJSONParsingJSON parsing failed
Other (internal)10000UnexpectedErrorUnknown error
The connection/entering-broadcast/during-broadcast codes (the -102 through -411 range) aren't thrown directly by the SDK in client code — instead they're a channel through which the SDK carries whatever value the server returns in the response body, as-is. In practice it is an enum that documents the server contract; the SDK does not actively check or branch on these values.

Android split across two systems

ShopLiveCommonErrorCode(SDK-internal, the 9xxx range) and ShopLiveCampaignErrorCode (server-response-only, and the source comment explicitly states "separate from the SDK-internal 9xxx system") exist separately.

CategoryCodeConstantMeaning
Connection (server response · ShopLiveCampaignErrorCode)-200SERVICE_NOT_EXISTAccess key doesn't exist
Connection (server response · ShopLiveCampaignErrorCode)-201ACCESS_KEY_EXPIREDExpired access key. needs verification: the source itself has a TODO comment saying "couldn't obtain an expired key to confirm whether this code is actually returned"
Other (server response · ShopLiveCampaignErrorCode)-510INVALID_PARAMETERInvalid request argument (this code doesn't even exist in the iOS list)
Other (internal · ShopLiveCommonErrorCode)9000NOT_INITIALIZED_ACCESS_KEYaccessKey not set
Other (internal)9001NOT_INITIALIZED_SHARE_URLNo share URL
Other (internal)9002NOT_INITIALIZED_CONFIGRaised when a server setting (shortform · host) is missing. Also occurs in shortform · editor
Other (internal)9003NOT_INITIALIZED_CAMPAIGN_KEYCampaign key not set. means something different from the same code on iOS (see the callout below)
Other (internal)9050REQUIRED_COMPONENT_ACTIVITYRequires ComponentActivity (no iOS counterpart)
Status notice (not an error)9101NO_PLAYABLE_STREAM_URLNo playback URL. onError delivers it, only while the campaign status is ONAIR
Status notice (not an error)9102LIVE_NOT_BROADCASTINGStream not started yet (no segments). onError delivers it, only while the campaign status is ONAIR
Other (internal)9300FAILED_ENCODINGEncoding failed
Other (internal)9301FAILED_VIDEO_ENCODINGVideo encoding failed (no iOS counterpart — iOS lumps this under a single 9300)
Other (internal)9302FAILED_COVER_ENCODINGCover image encoding failed (no iOS counterpart)
Other (internal)9310REMOVED_VIDEOVideo deleted
Other (internal)9320WITHOUT_THUMBNAILNo thumbnail (no iOS counterpart)
Other (internal)9500UNSUPPORTED_OS_PIP_MODEOS PIP not supported (below Android 8 · API 26)
Other (internal)9501UNSUPPORTED_MEDIAUnsupported codec
Other (internal)9800FAILED_PLAYERPlayer error (no iOS counterpart — iOS has no 9800 range at all)
Other (internal)9900FAILED_NETWORKNetwork failure
Other (internal)9901FAILED_JSON_PARSINGJSON parsing failed
Other (internal)9902FAILED_FETCH_DATAData lookup failed (no iOS counterpart)
Other (internal)10000UNEXPECTED_ERRORUnknown error
Android has no code corresponding to iOS's 9100 (NoVideoForPip). It starts at 9101. Many of the connection, entry and in-broadcast codes (-102 · -115 · -204 · -412 · -500 · -710 · -711 · -411) are not defined as constants anywhere in the Android source. When the server sends these values, they are passed straight through, without a constant, in the ShopLiveCommonError's code field. Like on iOS, this is meant for "documenting the server contract," so the two platforms don't conflict.
Codes in the 9100s are status notices, not errors 9100 · 9101 · 9102 arrive through the error callback, but they do not mean playback has ended. They report the campaign status, so do not close the player. If needed, show only a notice such as "Starting soon".
Code 9003 means something completely different on each platform iOS's 9003 is "invalid URL", while Android's 9003 is "campaign key not set". Mistake the platform from the number alone and you will diagnose entirely the wrong cause. Keep in mind that error code numbers are reused across platforms and the same code can mean different things.

Flutter

The Flutter SDK/sample has no error code constants or enum of its own. There's just a general-purpose pass-through class ShopLivePlayerError{code: String, message: String} is all there is. Treat the code values in the iOS and Android tables above as arriving verbatim as strings. Related: Reference's event section, the "Error" row.

Reference

Platform appendix

iOS supporting older Xcode versions

The official distribution (1.9.0) is based on iOS 15.0 and Swift 5, per the requirements table. If you can't use this officially distributed SDK, or your build fails, on an environment below Xcode 26, you can use a separately distributed SDK for backward compatibility.

Xcode versionSDK versionSwift version
16.01.8.16.0
16.41.8.2 · 1.8.46.1.2

The Swift version column in the table is taken from the original document and could not be re-verified against a podspec (needs verification). These versions ship as separate archives and are not registered in the CocoaPods spec repository. If you actually need to download one, ask your Shoplive contact.

iOS legacy SPM paths (1.8.x and below)

As of 1.9.0, the SPM distribution repository is unified into shoplive-sdk-ios as of 1.9.0. The earlier paths, ios-sdk·common-ios, remain 1.8.x-and-below only, remain for 1.8.x and below only. Don't use them for a new integration (see the SPM tab in Install).

Android ProGuard

There is nothing to configure. Each library ships its obfuscation rules inside itself as consumer-rules.pro. Add the dependency and they merge into your app's rules automatically at build time, so you never need to open your app's proguard-rules.pro.

The "add ProGuard rules manually" section in the old install guide no longer applies You used to have to copy the rules into your app by hand; that is no longer necessary. If you already copied rules from the old docs, leaving them in place does no harm.
If you want to check which module carries which rules

All nine modules in the domestic line declare build.gradle has consumerProguardFiles "consumer-rules.pro" in their own build.gradle, and all nine files really exist (nine declarations, nine files, verified directly against the SDK source).

shoplive-common · shoplive-exoplayer · shoplive-filter · shoplive-media3 · shoplive-network · shoplive-permission · shoplive-player · shoplive-short-form · shoplive-video-editor

There is more in them than commonly assumed. shoplive-network carries Gson keep rules, shoplive-player carries dontwarn entries for Coil, OkHttp and coroutines, and shoplive-short-form and shoplive-video-editor carry a number of Glide-related rules. They merge automatically anyway, so there is no need to look at them from your app.

Reference

FAQ · Troubleshooting

Frequently asked questions. Anything already covered in a mission isn't repeated here — it just links to that mission instead.

General

QuestionAnswer
Can this be applied to a Hybrid app (WebView-based)?Yes, it can. It works by integrating the native SDK into the hybrid app's native layer.
Can this be applied to a Flutter/React Native app?Flutter is supported by the shoplive_player package that this document covers (see Install). A React Native sample is also offered separately, but whether its download URL is actually reachable has not been verified. needs verification.
Do I need to install a separate library for video playback?No. Playback libraries like ExoPlayer/media3 are already bundled inside the SDK, so no separate installation is needed.
If I customize the UI, do I also need to modify the app code?No. UI customization is handled at the skin level through the option APIs (API · Options) the SDK provides, so no app-side code changes are needed.

iOS PIP

QuestionAnswer
Is there anything extra I need to do on the app side to implement PIP?Yes, if you use OS PIP. You have to turn on the Background Modes capability in your Xcode project, and state the purpose and a way to reproduce it when you submit for App Store review. The steps are in Mission 8 (OS PIP) for the procedure. State management for entering/switching PIP itself (window size, position, etc.) is handled by the SDK.
Does tapping a product automatically switch to PIP?Yes. When a link is tapped, the SDK switches to PIP right before it calls handleNavigation (setNextActionOnHandleNavigation defaults to .PIP). .KEEP · .CLOSE are also available. If you register hookNavigation, this does not happen (Mission 11).

Symptom. You enter the app through a deep link, watch a broadcast, tap a product and drop into PIP, and instead of your app coming to the front some other app appears. Launching from the app icon works fine.

Cause. When dropping into PIP, the player looks through ActivityManager.appTasks for your app's Task and brings it to the front. The marker it looks for is CATEGORY_LAUNCHER, and that marker is only attached to Tasks launched from the app icon. A Task created by a deep link does not have it, so depending on the back-stack shape the app cannot be found. The back-stack shape depends on whether whoever opened the link attached FLAG_ACTIVITY_NEW_TASK, and that is not something you control.

Fix. Put the deep link entry point in a separate activity such as SchemeActivity, and launch the real screen from there with FLAG_ACTIVITY_NEW_TASK. The Android example in Mission 2 is exactly this shape. Done this way the back stack looks the same no matter who opened the link. The full rules are in the Android deep link documentation.

Questions about playback state

If player or preview state values seem to behave differently on each platform, it may be that the values themselves differ. Reference's event section (player screen created/destroyed, screen closed) and the Data field dictionary (the Campaign entry)'s platform-difference callouts first.

Questions about error codes

The same code number can mean different things on different platforms. Don't infer the platform from the code value alone; check Reference's error codes as well.

Shoplive V1 Mobile SDK Integration Guide · Docs