pip 0.0.3 copy "pip: ^0.0.3" to clipboard
pip: ^0.0.3 copied to clipboard

A plugin for Picture in Picture.

example/lib/main.dart

import 'package:flutter/material.dart';
import 'dart:async';
import 'dart:io';

import 'native_view.dart';

import 'package:flutter/services.dart';
import 'package:pip/pip.dart';
import 'package:native_plugin/native_plugin.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Builder(
        builder: (context) => const HomePage(),
      ),
    );
  }
}

class HomePage extends StatefulWidget {
  const HomePage({super.key});

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
  final _pip = Pip();
  final _formKey = GlobalKey<FormState>();

  bool _isPipSupported = false;
  bool _isPipAutoEnterSupported = false;
  bool _isPipActived = false;
  int _playerView = 0;
  int _pipContentView = 0;
  int _currentImageIndex = 0;
  Timer? _imageTimer;

  final List<String> _imagePaths = [
    'images/PIP1.png',
    'images/PIP2.png',
    'images/PIP3.png',
  ];

  // Add controllers for input fields
  late final TextEditingController _aspectRatioXController;
  late final TextEditingController _aspectRatioYController;
  bool _autoEnterEnabled = false;

  final _nativePlugin = NativePlugin();

  AppLifecycleState _lastAppLifecycleState = AppLifecycleState.resumed;

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);

    // We highly recommend to set the aspect ratio or preferred width and height based on the screen size, 
    // which will make the PiP experience more seamless.
    
    // Initialize controllers with default values based on platform
    final size = WidgetsBinding.instance.window.physicalSize;
    final scale = WidgetsBinding.instance.window.devicePixelRatio;
    final width = size.width / scale;
    final height = size.height / scale;
    
    if (Platform.isIOS) {
      _aspectRatioXController = TextEditingController(text: width.toStringAsFixed(0));
      _aspectRatioYController = TextEditingController(text: height.toStringAsFixed(0));
    } else {
      // Find the simplest ratio that matches the aspect ratio
      int gcd(int a, int b) {
        while (b != 0) {
          final t = b;
          b = a % b;
          a = t;
        }
        return a;
      }
      
      final divisor = gcd(width.toInt(), height.toInt());
      final x = (width / divisor).round();
      final y = (height / divisor).round();
      
      _aspectRatioXController = TextEditingController(text: x.toString());
      _aspectRatioYController = TextEditingController(text: y.toString());
    }
    
    initPlatformState();
    if (Platform.isAndroid) {
      _startImageTimer();
    }
  }

  void _startImageTimer() {
    _imageTimer = Timer.periodic(const Duration(seconds: 3), (timer) {
      setState(() {
        _currentImageIndex = (_currentImageIndex + 1) % _imagePaths.length;
      });
    });
  }

  @override
  void dispose() {
    if (Platform.isIOS && _pipContentView != 0) {
      _nativePlugin.disposePipContentView(_pipContentView);
    }

    _imageTimer?.cancel();
    WidgetsBinding.instance.removeObserver(this);
    _aspectRatioXController.dispose();
    _aspectRatioYController.dispose();
    super.dispose();
  }

  @override
  Future<void> didChangeAppLifecycleState(AppLifecycleState state) async {
    super.didChangeAppLifecycleState(state);
    print("[didChangeAppLifecycleState]: $state");

    if (state == AppLifecycleState.inactive) {
      // We recommend to set the autoEnterEnabled to true if the pip is auto enter supported not to call pipStart on inactive state.
      // https://developer.apple.com/documentation/avkit/adopting-picture-in-picture-in-a-custom-player?language=objc#Handle-User-Initiated-Requests
      // Important:
      // Only begin PiP playback in response to user interaction and never programmatically.
      // The App Store review team rejects apps that fail to follow this requirement.
      if (_lastAppLifecycleState != AppLifecycleState.paused &&
          !_isPipAutoEnterSupported) {
        await _pip.start();
      }
    } else if (state == AppLifecycleState.resumed) {
      if (!Platform.isAndroid) {
        // on Android, the pipStop is not supported, the pipStop operation is only bring the activity to background.
        await _pip.stop();
      }
    }

    // The AppLifecycleState.hidden state was introduced in Flutter 3.13.0 to handle when
    // the app is temporarily hidden but not paused (e.g. during app switching on iOS).
    // Since this code needs to support Flutter 3.7.0+ for compatibility, we use
    // a switch statement that only handles lifecycle states available in all supported versions.
    // This allows us to safely ignore the hidden state and avoid unintentionally entering PiP
    // mode when the app recovers from being paused.
    // See: https://docs.flutter.dev/release/breaking-changes/add-applifecyclestate-hidden
    switch (state) {
      case AppLifecycleState.resumed:
      case AppLifecycleState.inactive:
      case AppLifecycleState.paused:
      case AppLifecycleState.detached:
        if (_lastAppLifecycleState != state) {
          setState(() {
            _lastAppLifecycleState = state;
          });
        }
        break;
      default:
        break;
    }
  }

  // Platform messages are asynchronous, so we initialize in an async method.
  Future<void> initPlatformState() async {
    bool pipIsSupported = false;
    bool pipIsAutoEnterSupported = false;
    bool isPipActived = false;
    try {
      var platformVersion = await _nativePlugin.getPlatformVersion();
      print('[platformVersion]: $platformVersion');
      pipIsSupported = await _pip.isSupported();
      pipIsAutoEnterSupported = await _pip.isAutoEnterSupported();
      isPipActived = await _pip.isActived();
      await _pip.registerStateChangedObserver(PipStateChangedObserver(
        onPipStateChanged: (state, error) {
          print('[onPipStateChanged] state: $state, error: $error');
          setState(() {
            _isPipActived = state == PipState.pipStateStarted;
          });

          if (state == PipState.pipStateFailed) {
            print('[onPipStateChanged] state: $state, error: $error');
            // if you destroy the source view of pip controller, some error may happen,
            // so we need to dispose the pip controller here.
            _pip.dispose();
          }
        },
      ));
    } on PlatformException {
      pipIsSupported = false;
      pipIsAutoEnterSupported = false;
      isPipActived = false;
    }

    // If the widget was removed from the tree while the asynchronous platform
    // message was in flight, we want to discard the reply rather than calling
    // setState to update our non-existent appearance.
    if (!mounted) return;

    setState(() {
      _isPipSupported = pipIsSupported;
      _isPipAutoEnterSupported = pipIsAutoEnterSupported;
      _isPipActived = isPipActived;

      // set the autoEnterEnabled to true if the pip is auto enter supported
      _autoEnterEnabled = pipIsAutoEnterSupported;
    });
  }

  Future<void> _setupPip() async {
    if (_formKey.currentState!.validate()) {
      if (Platform.isIOS && _pipContentView == 0) {
        _pipContentView = await _nativePlugin.createPipContentView();
        print('[createPipContentView]: $_pipContentView');

        setState(() {
          _pipContentView = _pipContentView;
        });
      }
      final options = PipOptions(
        // According to https://developer.android.com/develop/ui/views/picture-in-picture#setautoenterenabled and Apple documentation
        // Both platforms recommend setting autoEnterEnabled to true for the best user experience.
        autoEnterEnabled: _autoEnterEnabled,

        // android only
        // The aspect ratio of the source view, keep same as the aspect ratio of the player view.
        aspectRatioX: int.tryParse(_aspectRatioXController.text),
        aspectRatioY: int.tryParse(_aspectRatioYController.text),
        // According to https://developer.android.com/develop/ui/views/picture-in-picture#set-sourcerecthint
        // If your app doesn't provide a proper sourceRectHint, the system tries to apply a content overlay
        // during the PiP entering animation, which makes for a poor user experience.
        sourceRectHintLeft: 0,
        sourceRectHintTop: 0,
        sourceRectHintRight: 0,
        sourceRectHintBottom: 0,
        // According to https://developer.android.com/develop/ui/views/picture-in-picture#seamless-resizing
        // The setSeamlessResizeEnabled flag is set to true by default for backward compatibility.
        // Leave this set to true for video content, and change it to false for non-video content.
        seamlessResizeEnabled: true,
        // The external state monitor checks the PiP view state at the interval specified by externalStateMonitorInterval (100ms).
        // This is necessary because FlutterActivity does not forward PiP state change events to the Flutter side.
        // Even if your Activity is a subclass of PipActivity, you can still use the external state monitor to track PiP state changes.
        useExternalStateMonitor: true,
        externalStateMonitorInterval: 100,

        // ios only
        contentView: _pipContentView,
        sourceContentView: _playerView,
        preferredContentWidth: int.tryParse(_aspectRatioXController.text),
        preferredContentHeight: int.tryParse(_aspectRatioYController.text),
        controlStyle: 2,
      );

      try {
        final success = await _pip.setup(options);
        print('PiP Setup ${success ? 'successful' : 'failed'}');
      } catch (e) {
        print('PiP Setup error: $e');
      }
    }
  }

  Widget _buildPipView() {
    if (!Platform.isAndroid) {
      return SizedBox(
        height: 200,
        child: NativeWidget(
          onPlatformViewCreated: (id, internalViewId) {
            print(
                'Platform view created: $id, internalViewId: $internalViewId');
            setState(() {
              _playerView = internalViewId;
            });
          },
        ),
      );
    }

    return Center(
      child: Builder(
        builder: (context) {
          try {
            return LayoutBuilder(
              builder: (context, constraints) {
                final size = MediaQuery.of(context).size;
                final imageWidth = size.width;
                final imageHeight = size.height;
                
                return Image.asset(
                  _imagePaths[_currentImageIndex],
                  width: imageWidth,
                  height: imageHeight,
                  fit: BoxFit.contain,
                  errorBuilder: (context, error, stackTrace) {
                    print('Error loading image: $error');
                    print('Stack trace: $stackTrace');
                    return const Text('Error loading image');
                  },
                );
              },
            );
          } catch (e) {
            print('Exception while loading image: $e');
            return Text('Exception: $e');
          }
        },
      ),
    );
  }

  Widget _buildPipFunctions() {
    return Wrap(
      spacing: 8.0,
      runSpacing: 8.0,
      alignment: WrapAlignment.start,
      children: [
        if (_isPipAutoEnterSupported)
          CheckboxListTile(
            contentPadding: EdgeInsets.zero,
            title: const Text(
              'Auto Enter Enabled',
              style: TextStyle(color: Colors.white),
            ),
            value: _autoEnterEnabled,
            onChanged: (value) =>
                setState(() => _autoEnterEnabled = value ?? false),
            activeColor: Colors.blue,
            checkColor: Colors.white,
          ),
        Row(
          children: [
            Expanded(
              child: TextFormField(
                controller: _aspectRatioXController,
                decoration: InputDecoration(
                  labelText: (Platform.isAndroid
                      ? 'Aspect Ratio X'
                      : 'Preferred Width'),
                  labelStyle: const TextStyle(color: Colors.white),
                  enabledBorder: const UnderlineInputBorder(
                    borderSide: BorderSide(color: Colors.white),
                  ),
                  focusedBorder: const UnderlineInputBorder(
                    borderSide: BorderSide(color: Colors.white),
                  ),
                ),
                style: const TextStyle(color: Colors.white),
                keyboardType: TextInputType.number,
                validator: (value) {
                  if (value == null || int.tryParse(value) == null) {
                    return 'Please enter a valid number';
                  }
                  return null;
                },
              ),
            ),
            const SizedBox(width: 8),
            Expanded(
              child: TextFormField(
                controller: _aspectRatioYController,
                decoration: InputDecoration(
                  labelText: (Platform.isAndroid
                      ? 'Aspect Ratio Y'
                      : 'Preferred Height'),
                  labelStyle: const TextStyle(color: Colors.white),
                  enabledBorder: const UnderlineInputBorder(
                    borderSide: BorderSide(color: Colors.white),
                  ),
                  focusedBorder: const UnderlineInputBorder(
                    borderSide: BorderSide(color: Colors.white),
                  ),
                ),
                style: const TextStyle(color: Colors.white),
                keyboardType: TextInputType.number,
                validator: (value) {
                  if (value == null || int.tryParse(value) == null) {
                    return 'Please enter a valid number';
                  }
                  return null;
                },
              ),
            ),
          ],
        ),
        ElevatedButton(
          onPressed: _setupPip,
          style: ElevatedButton.styleFrom(
            backgroundColor: Colors.blue,
            foregroundColor: Colors.white,
          ),
          child: const Text('Setup'),
        ),
        if (!_isPipActived)
          ElevatedButton(
            onPressed: () async {
              try {
                final success = await _pip.start();
                print('PiP Start ${success ? 'successful' : 'failed'}');
              } catch (e) {
                print('PiP Start error: $e');
              }
            },
            style: ElevatedButton.styleFrom(
              backgroundColor: Colors.blue,
              foregroundColor: Colors.white,
            ),
            child: const Text('Start'),
          ),
        if (_isPipActived && !Platform.isAndroid)
          ElevatedButton(
            onPressed: () async {
              try {
                await _pip.stop();
                print('PiP Stopped');
              } catch (e) {
                print('PiP Stop error: $e');
              }
            },
            style: ElevatedButton.styleFrom(
              backgroundColor: Colors.blue,
              foregroundColor: Colors.white,
            ),
            child: const Text('Stop'),
          ),
        ElevatedButton(
          onPressed: () async {
            try {
              await _pip.dispose();
              print('PiP Disposed');
            } catch (e) {
              print('PiP Dispose error: $e');
            }
          },
          style: ElevatedButton.styleFrom(
            backgroundColor: Colors.blue,
            foregroundColor: Colors.white,
          ),
          child: const Text('Dispose'),
        ),
      ],
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.black,
      body: _isPipSupported
          ? Stack(
              children: [
                // Bottom layer: PIP View
                Positioned.fill(
                  child: _buildPipView(),
                ),
                // Overlay layer: Controls
                if (!(Platform.isAndroid && _isPipActived))
                  Positioned(
                    bottom: 0,
                    left: 0,
                    right: 0,
                    child: Container(
                      padding: const EdgeInsets.all(16.0),
                      child: SingleChildScrollView(
                        child: Form(
                          key: _formKey,
                          child: Column(
                            crossAxisAlignment: CrossAxisAlignment.start,
                            children: [
                              _buildPipFunctions(),
                            ],
                          ),
                        ),
                      ),
                    ),
                  ),
              ],
            )
          : const Center(
              child: Text(
                'Pip is not supported',
                style: TextStyle(color: Colors.white),
              ),
            ),
    );
  }
}
16
likes
150
points
158
downloads

Publisher

verified publisheropentraa.org

Weekly Downloads

A plugin for Picture in Picture.

Repository (GitHub)

Documentation

API reference

License

BSD-3-Clause (license)

Dependencies

flutter, plugin_platform_interface

More

Packages that depend on pip