Flutter WebView Bridge 설정 가이드

Flutter WebView Bridge 설정 가이드

Flutter 애플리케이션과 SauceLive/SauceClip 웹 콘텐츠 간의 통신을 구현하기 위한 가이드입니다.

⚠️

중요: 이 가이드는 webview_flutter v4.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)

<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)

<!-- 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": "..."}
sauceflexPictureInPicturePIP 모드 요청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 브릿지 호출 방식 비교

플랫폼네이티브 등록 방식웹에서 호출 방식
iOScontroller.add(self, name: "sauceflexMoveProduct")webkit.messageHandlers.sauceflexMoveProduct.postMessage(data)
AndroidwebView.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);
}