Flutter WebView Bridge 설정 가이드
Flutter WebView Bridge 설정 가이드
Flutter 애플리케이션과 SauceLive/SauceClip 웹 콘텐츠 간의 통신을 구현하기 위한 가이드입니다.
중요: 이 가이드는webview_flutterv4.x 기준으로 작성되었습니다. v3.x와 API가 다르므로 주의하세요.
패키지 추가
프로젝트의 pubspec.yaml 파일에 패키지를 추가합니다.
dependencies:
flutter:
sdk: flutter
webview_flutter: ^4.10.0
webview_flutter_wkwebview: ^3.23.5 # iOS 미디어 설정 필요
share_plus: ^10.1.4
url_launcher: ^6.3.1명령어를 실행하여 패키지를 설치합니다:
flutter pub get플랫폼 설정
Android (android/app/src/main/AndroidManifest.xml)
android/app/src/main/AndroidManifest.xml)<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 인터넷 권한 추가 -->
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<application
android:usesCleartextTraffic="true"
...>iOS (ios/Runner/Info.plist)
ios/Runner/Info.plist)<!-- HTTP 허용 설정 -->
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>⚠️ 중요: 브릿지 채널 등록 방식
❌ 잘못된 방식 (작동하지 않음)
// 단일 채널로 모든 이벤트를 처리하려는 방식 - 작동 안함!
javascriptChannels: <JavascriptChannel>{
JavascriptChannel(
name: 'SauceflexBridge',
onMessageReceived: (message) {
// { event: "sauceflexMoveProduct", payload: {...} } 기대
},
),
},✅ 올바른 방식 (이벤트별 개별 채널 등록)
웹페이지는 플랫폼에 따라 다르게 브릿지를 호출합니다:
- iOS:
webkit.messageHandlers.sauceflexMoveProduct.postMessage(data) - Android:
window.sauceflex.sauceflexMoveProduct(data)
따라서 각 이벤트 이름마다 개별 채널을 등록해야 합니다.
WebView 위젯 설정
전체 샘플 코드
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart';
import 'package:share_plus/share_plus.dart';
import 'package:url_launcher/url_launcher.dart';
class SauceLiveWebView extends StatefulWidget {
final String liveId;
final String accessToken;
const SauceLiveWebView({
Key? key,
required this.liveId,
required this.accessToken,
}) : super(key: key);
@override
State<SauceLiveWebView> createState() => _SauceLiveWebViewState();
}
class _SauceLiveWebViewState extends State<SauceLiveWebView> {
late final WebViewController _controller;
/// 등록할 브릿지 이름들 (iOS + Android 통합)
static const List<String> _bridgeNames = [
// SauceFlex Live 브릿지 (공통)
'sauceflexEnter',
'sauceflexMoveExit',
'sauceflexMoveLogin',
'sauceflexMoveProduct',
'sauceflexMoveBanner',
'sauceflexMoveCoupon',
'sauceflexMoveReward',
'sauceflexOnShare',
'sauceflexPictureInPicture',
'sauceflexPictureInPictureOn',
'sauceflexMoveSlotBanner',
'sauceflexOnClose',
// iOS 전용
'sauceflexBannerIosScheme',
'sauceflexProductIosScheme',
'sauceflexTokenError',
'sauceflexSetCustomCoupon',
'sauceflexIssueCoupon',
// Android 전용
'sauceflexBroadcastStatus',
'sauceflexWebviewReloading',
'sauceflexSlotBannerAndroidScheme',
// SauceClip 브릿지
'sauceclipEnter',
'sauceclipMoveExit',
'sauceclipMoveLogin',
'sauceclipMoveProduct',
'sauceclipOnShare',
'sauceclipPictureInPicture',
'sauceclipMoveCart',
];
String get _initialUrl =>
'https://player.sauceflex.com/broadcast/${widget.liveId}?accessToken=${widget.accessToken}';
@override
void initState() {
super.initState();
_initWebViewController();
}
void _initWebViewController() {
// iOS 플랫폼별 설정 (미디어 인라인 재생 등)
late final PlatformWebViewControllerCreationParams params;
if (Platform.isIOS) {
params = WebKitWebViewControllerCreationParams(
allowsInlineMediaPlayback: true, // 인라인 미디어 재생 허용
mediaTypesRequiringUserAction: const <PlaybackMediaTypes>{}, // 자동 재생 허용
);
} else {
params = const PlatformWebViewControllerCreationParams();
}
_controller = WebViewController.fromPlatformCreationParams(params)
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setNavigationDelegate(
NavigationDelegate(
onPageFinished: (String url) {
// 페이지 로딩 완료 후 브릿지 스크립트 주입
_injectBridgeScript();
},
onNavigationRequest: (NavigationRequest request) {
// tel:, mailto: 등 외부 스킴 처리
if (!request.url.startsWith('http://') &&
!request.url.startsWith('https://') &&
!request.url.startsWith('about:')) {
_launchExternalUrl(request.url);
return NavigationDecision.prevent;
}
return NavigationDecision.navigate;
},
),
);
// ✅ 각 브릿지 이름별로 JavaScript 채널 등록
for (final bridgeName in _bridgeNames) {
_controller.addJavaScriptChannel(
bridgeName,
onMessageReceived: (JavaScriptMessage message) {
debugPrint('🌉 [$bridgeName] 수신: ${message.message}');
_handleBridgeMessage(bridgeName, message.message);
},
);
}
// URL 로드
_controller.loadRequest(Uri.parse(_initialUrl));
}
/// iOS + Android 호환 브릿지 스크립트 주입
Future<void> _injectBridgeScript() async {
const bridgeScript = '''
(function() {
if (window.__flutterBridgeInitialized) return;
window.__flutterBridgeInitialized = true;
var bridgeNames = [
'sauceflexEnter', 'sauceflexMoveExit', 'sauceflexMoveLogin',
'sauceflexMoveProduct', 'sauceflexMoveBanner', 'sauceflexMoveCoupon',
'sauceflexMoveReward', 'sauceflexOnShare', 'sauceflexPictureInPicture',
'sauceflexPictureInPictureOn', 'sauceflexMoveSlotBanner', 'sauceflexOnClose',
'sauceflexBannerIosScheme', 'sauceflexProductIosScheme', 'sauceflexTokenError',
'sauceflexSetCustomCoupon', 'sauceflexIssueCoupon',
'sauceflexBroadcastStatus', 'sauceflexWebviewReloading', 'sauceflexSlotBannerAndroidScheme',
'sauceclipEnter', 'sauceclipMoveExit', 'sauceclipMoveLogin',
'sauceclipMoveProduct', 'sauceclipOnShare', 'sauceclipPictureInPicture', 'sauceclipMoveCart'
];
function sendToFlutter(name, message) {
var msg = (message === undefined || message === null) ? '' :
(typeof message === 'string' ? message : JSON.stringify(message));
if (window[name] && window[name].postMessage) {
window[name].postMessage(msg);
}
}
// iOS 방식: webkit.messageHandlers.xxx.postMessage(data)
if (!window.webkit) window.webkit = {};
if (!window.webkit.messageHandlers) window.webkit.messageHandlers = {};
bridgeNames.forEach(function(name) {
window.webkit.messageHandlers[name] = {
postMessage: function(message) {
sendToFlutter(name, message);
}
};
});
// Android 방식: window.sauceflex.xxx(data)
window.sauceflex = {};
bridgeNames.forEach(function(name) {
window.sauceflex[name] = function(message) {
sendToFlutter(name, message);
};
});
console.log('[Flutter Bridge] Initialized!');
})();
''';
await _controller.runJavaScript(bridgeScript);
}
/// 브릿지 메시지 처리
void _handleBridgeMessage(String bridgeName, String rawMessage) {
dynamic payload;
try {
payload = jsonDecode(rawMessage);
} catch (e) {
payload = rawMessage;
}
switch (bridgeName) {
case 'sauceflexEnter':
_handleEnter();
break;
case 'sauceflexMoveExit':
case 'sauceclipMoveExit':
_handleMoveExit();
break;
case 'sauceflexMoveLogin':
case 'sauceclipMoveLogin':
_handleMoveLogin();
break;
case 'sauceflexMoveProduct':
case 'sauceclipMoveProduct':
_handleMoveProduct(payload);
break;
case 'sauceflexMoveBanner':
_handleMoveBanner(payload);
break;
case 'sauceflexMoveCoupon':
_handleMoveCoupon(payload);
break;
case 'sauceflexMoveReward':
_handleMoveReward(payload);
break;
case 'sauceflexOnShare':
case 'sauceclipOnShare':
_handleShare(payload);
break;
case 'sauceflexPictureInPicture':
case 'sauceflexPictureInPictureOn':
case 'sauceclipPictureInPicture':
_handlePictureInPicture();
break;
case 'sauceflexMoveSlotBanner':
_handleMoveSlotBanner(payload);
break;
case 'sauceflexBannerIosScheme':
case 'sauceflexProductIosScheme':
case 'sauceflexSlotBannerAndroidScheme':
_handleScheme(payload);
break;
case 'sauceflexBroadcastStatus':
_handleBroadcastStatus(payload);
break;
case 'sauceflexWebviewReloading':
_handleWebviewReloading();
break;
case 'sauceclipMoveCart':
_handleMoveCart(payload);
break;
case 'sauceflexOnClose':
_handleOnClose();
break;
default:
debugPrint('기타 브릿지: $bridgeName');
}
}
// ========== 핸들러 구현 ==========
void _handleEnter() {
debugPrint('플레이어 진입');
// PIP 사용 가능 알림
_controller.runJavaScript(
"(function() { window.dispatchEvent(sauceflexPictureInPictureUse(true)); })();"
);
}
void _handleMoveExit() {
debugPrint('플레이어 종료');
if (Navigator.canPop(context)) {
Navigator.pop(context);
}
}
void _handleMoveLogin() {
debugPrint('로그인 요청');
// TODO: 로그인 화면으로 네비게이션
}
void _handleMoveProduct(dynamic payload) {
debugPrint('상품 이동: $payload');
String? linkUrl;
if (payload is Map) {
linkUrl = payload['linkUrl'] as String?;
} else if (payload is String) {
try {
final json = jsonDecode(payload);
linkUrl = json['linkUrl'] as String?;
} catch (e) {
debugPrint('JSON 파싱 실패');
}
}
if (linkUrl != null) {
_launchExternalUrl(linkUrl);
}
}
Future<void> _handleMoveBanner(dynamic payload) async {
debugPrint('배너 이동: $payload');
String? linkUrl;
if (payload is Map) {
linkUrl = payload['linkUrl'] as String?;
} else if (payload is String) {
try {
final json = jsonDecode(payload);
linkUrl = json['linkUrl'] as String?;
} catch (e) {
debugPrint('JSON 파싱 실패');
}
}
if (linkUrl != null) {
await _launchExternalUrl(linkUrl);
}
}
void _handleMoveCoupon(dynamic payload) {
debugPrint('쿠폰: $payload');
// TODO: 쿠폰 발급 API 호출 및 안내 팝업
}
void _handleMoveReward(dynamic payload) {
debugPrint('리워드: $payload');
// TODO: 포인트 적립/완료 UI 처리
}
void _handleShare(dynamic payload) {
debugPrint('공유: $payload');
String? shortUrl;
String? title;
if (payload is Map) {
shortUrl = payload['shortUrl'] as String? ?? payload['linkUrl'] as String?;
title = payload['title'] as String?;
} else if (payload is String) {
try {
final json = jsonDecode(payload);
shortUrl = json['shortUrl'] as String? ?? json['linkUrl'] as String?;
title = json['title'] as String?;
} catch (e) {
shortUrl = payload;
}
}
if (shortUrl != null) {
final text = title != null ? '$title\n$shortUrl' : shortUrl;
Share.share(text);
}
}
void _handlePictureInPicture() {
debugPrint('PIP 요청');
const js = '''
(function() {
var video = document.querySelector('video');
if (video && document.pictureInPictureEnabled && !video.disablePictureInPicture) {
video.requestPictureInPicture();
}
})();
''';
_controller.runJavaScript(js);
}
Future<void> _handleMoveSlotBanner(dynamic payload) async {
debugPrint('슬롯배너: $payload');
await _handleScheme(payload);
}
Future<void> _handleScheme(dynamic payload) async {
debugPrint('스킴: $payload');
String? url;
String? webUrl;
if (payload is Map) {
url = payload['url'] as String? ?? payload['schemeUrl'] as String?;
webUrl = payload['webUrl'] as String?;
} else if (payload is String) {
try {
final json = jsonDecode(payload);
url = json['url'] as String? ?? json['schemeUrl'] as String?;
webUrl = json['webUrl'] as String?;
} catch (e) {
url = payload;
}
}
if (url != null) {
final uri = Uri.tryParse(url);
if (uri != null && await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else if (webUrl != null) {
// 앱 스킴을 열 수 없으면 웹 URL로 대체
await _launchExternalUrl(webUrl);
}
}
}
void _handleBroadcastStatus(dynamic payload) {
debugPrint('방송 상태 변경: $payload');
// Android 전용: 라이브 상태 변경 시 처리
}
void _handleWebviewReloading() {
debugPrint('웹뷰 리로딩');
_controller.reload();
}
void _handleMoveCart(dynamic payload) {
debugPrint('장바구니: $payload');
// TODO: 장바구니 처리
}
void _handleOnClose() {
debugPrint('닫기 요청');
if (Navigator.canPop(context)) {
Navigator.pop(context);
}
}
Future<void> _launchExternalUrl(String url) async {
final uri = Uri.tryParse(url);
if (uri == null) return;
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('SauceLive Player'),
),
body: WebViewWidget(controller: _controller),
);
}
}주요 브릿지 이벤트 목록
공통 이벤트 (iOS + Android)
| 이벤트명 | 설명 | payload 예시 |
|---|---|---|
sauceflexEnter | 플레이어 진입 | null |
sauceflexMoveExit | 플레이어 종료 | null |
sauceflexMoveLogin | 로그인 요청 | null |
sauceflexMoveProduct | 상품 클릭 | {"linkUrl": "https://...", "productId": "P123"} |
sauceflexMoveBanner | 배너 클릭 | {"linkUrl": "https://...", "bannerId": "B001"} |
sauceflexMoveCoupon | 쿠폰 클릭 | {"couponId": "C100", "title": "쿠폰명"} |
sauceflexMoveReward | 리워드 지급 | {"rewardId": "R10", "point": 100} |
sauceflexOnShare | 공유하기 | {"linkUrl": "...", "shortUrl": "...", "title": "..."} |
sauceflexPictureInPicture | PIP 모드 요청 | null |
sauceflexMoveSlotBanner | 슬롯 배너 클릭 | {"url": "...", "webUrl": "..."} |
iOS 전용 이벤트
| 이벤트명 | 설명 | payload 예시 |
|---|---|---|
sauceflexBannerIosScheme | 배너 iOS 스킴 | {"url": "myapp://...", "webUrl": "https://..."} |
sauceflexProductIosScheme | 상품 iOS 스킴 | {"url": "myapp://...", "webUrl": "https://..."} |
sauceflexTokenError | 토큰 에러 | null |
Android 전용 이벤트
| 이벤트명 | 설명 | payload 예시 |
|---|---|---|
sauceflexBroadcastStatus | 방송 상태 변경 | {"status": "live"} |
sauceflexWebviewReloading | 웹뷰 리로딩 요청 | null |
sauceflexSlotBannerAndroidScheme | 슬롯 배너 Android 스킴 | {"schemeUrl": "myapp://..."} |
SauceClip 이벤트
| 이벤트명 | 설명 | payload 예시 |
|---|---|---|
sauceclipEnter | 클립 진입 | null |
sauceclipMoveExit | 클립 종료 | null |
sauceclipMoveLogin | 로그인 요청 | null |
sauceclipMoveProduct | 상품 클릭 | {"linkUrl": "https://..."} |
sauceclipOnShare | 공유하기 | {"shortUrl": "..."} |
sauceclipMoveCart | 장바구니 이동 | {...} |
iOS/Android 브릿지 호출 방식 비교
| 플랫폼 | 네이티브 등록 방식 | 웹에서 호출 방식 |
|---|---|---|
| iOS | controller.add(self, name: "sauceflexMoveProduct") | webkit.messageHandlers.sauceflexMoveProduct.postMessage(data) |
| Android | webView.addJavascriptInterface(obj, "sauceflex") | window.sauceflex.sauceflexMoveProduct(data) |
| Flutter | _controller.addJavaScriptChannel("sauceflexMoveProduct", ...) | 위 두 방식 모두 지원 (스크립트 주입) |
Flutter에서 웹 페이지로 메시지 보내기
// v4.x API
Future<void> sendMessageToWeb(String message) async {
await _controller.runJavaScript("window.receiveFromFlutter('$message');");
}
// JavaScript 실행 결과 받기
Future<Object> runJavaScriptWithResult(String script) async {
return await _controller.runJavaScriptReturningResult(script);
}Updated 11 days ago
