From fb4821ffdd4453bce21d637bcb0bed854d67f8e6 Mon Sep 17 00:00:00 2001 From: Kieran Date: Wed, 28 May 2025 16:28:24 +0100 Subject: [PATCH] feat: background playback (wip) https://github.com/ryanheise/audio_service/issues/1124 --- android/app/build.gradle.kts | 5 +- android/app/src/debug/AndroidManifest.xml | 4 +- android/app/src/main/AndroidManifest.xml | 68 ++++++++---- ios/Runner/Info.plist | 1 + lib/app.dart | 6 +- lib/main.dart | 13 +++ lib/pages/stream.dart | 6 +- lib/player.dart | 100 ++++++++++++++++++ lib/widgets/video_player_main.dart | 53 ++++++++++ macos/Flutter/GeneratedPluginRegistrant.swift | 4 + pubspec.lock | 32 ++++++ pubspec.yaml | 3 +- 12 files changed, 263 insertions(+), 32 deletions(-) create mode 100644 lib/player.dart create mode 100644 lib/widgets/video_player_main.dart diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 9e76d61..5db1186 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,5 +1,4 @@ import com.android.build.api.dsl.ApkSigningConfig -import com.android.build.api.dsl.SigningConfig import org.jetbrains.kotlin.gradle.targets.js.toHex import java.io.FileInputStream import java.util.Base64 @@ -8,11 +7,9 @@ import java.util.Properties plugins { id("com.android.application") - // START: FlutterFire Configuration - id("com.google.gms.google-services") - // END: FlutterFire Configuration id("kotlin-android") id("dev.flutter.flutter-gradle-plugin") + id("com.google.gms.google-services") } fun getKeystoreFile(base64String: String?, hash: String, fileName: String): File { diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml index 67fffa0..6be01e2 100644 --- a/android/app/src/debug/AndroidManifest.xml +++ b/android/app/src/debug/AndroidManifest.xml @@ -3,6 +3,8 @@ the Flutter tool needs it to communicate with the running application to allow setting breakpoints, to provide hot reload, etc. --> - + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 587f5a0..cddc286 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,52 +1,73 @@ - + + + + + + + + android:icon="@mipmap/ic_launcher" + android:label="zap.stream"> - + android:name="io.flutter.embedding.android.NormalTheme" + android:resource="@style/NormalTheme" /> + - - + + + - + + + + + - + + + + + + + + + + - - - - - + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 3ed80d2..6c247bf 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -72,6 +72,7 @@ fetch remote-notification + audio UILaunchStoryboardName LaunchScreen diff --git a/lib/app.dart b/lib/app.dart index 8bd6d94..6ae5bdd 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -25,7 +25,11 @@ void runZapStream() { supportedLocales: AppLocaleUtils.supportedLocales, localizationsDelegates: GlobalMaterialLocalizations.delegates, theme: ThemeData.localize( - ThemeData(colorScheme: ColorScheme.dark(), highlightColor: PRIMARY_1), + ThemeData( + colorScheme: ColorScheme.dark(), + highlightColor: PRIMARY_1, + useMaterial3: true, + ), TextTheme(), ), routerConfig: GoRouter( diff --git a/lib/main.dart b/lib/main.dart index 76ccaca..23a029f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,6 @@ import 'dart:developer' as developer; +import 'package:audio_service/audio_service.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; @@ -7,6 +8,9 @@ import 'package:zap_stream_flutter/app.dart'; import 'package:zap_stream_flutter/const.dart'; import 'package:zap_stream_flutter/i18n/strings.g.dart'; import 'package:zap_stream_flutter/notifications.dart'; +import 'package:zap_stream_flutter/player.dart'; + +late final MainPlayer mainPlayer; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -20,5 +24,14 @@ Future main() async { developer.log("Failed to setup notifications: $e"); }); + mainPlayer = await AudioService.init( + builder: () => MainPlayer(), + config: AudioServiceConfig( + androidNotificationChannelId: "io.nostrlabs.zap_stream_flutter.player", + androidNotificationChannelName: "player", + androidNotificationOngoing: true + ), + ); + runZapStream(); } diff --git a/lib/pages/stream.dart b/lib/pages/stream.dart index 8bc6fab..ebd9c4b 100644 --- a/lib/pages/stream.dart +++ b/lib/pages/stream.dart @@ -17,7 +17,7 @@ import 'package:zap_stream_flutter/widgets/notifications_button.dart'; import 'package:zap_stream_flutter/widgets/pill.dart'; import 'package:zap_stream_flutter/widgets/profile.dart'; import 'package:zap_stream_flutter/widgets/stream_info.dart'; -import 'package:zap_stream_flutter/widgets/video_player.dart'; +import 'package:zap_stream_flutter/widgets/video_player_main.dart'; import 'package:zap_stream_flutter/widgets/zap.dart'; class StreamPage extends StatefulWidget { @@ -141,11 +141,13 @@ class _StreamPage extends State with RouteAware { aspectRatio: 16 / 9, child: (stream.info.stream != null && !_offScreen) - ? VideoPlayerWidget( + ? MainVideoPlayerWidget( url: stream.info.stream!, placeholder: stream.info.image, aspectRatio: 16 / 9, isLive: true, + title: stream.info.title, + ) : (stream.info.image?.isNotEmpty ?? false) ? ProxyImg(url: stream.info.image) diff --git a/lib/player.dart b/lib/player.dart new file mode 100644 index 0000000..3e60a59 --- /dev/null +++ b/lib/player.dart @@ -0,0 +1,100 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:chewie/chewie.dart'; +import 'package:video_player/video_player.dart'; +import 'package:zap_stream_flutter/const.dart'; +import 'package:zap_stream_flutter/imgproxy.dart'; + +class MainPlayer extends BaseAudioHandler { + VideoPlayerController? _controller; + ChewieController? _chewieController; + + ChewieController? get chewie { + return _chewieController; + } + + @override + Future play() async { + await _chewieController?.play(); + } + + @override + Future pause() async { + await _chewieController?.pause(); + } + + @override + Future stop() async { + await _chewieController?.pause(); + } + + void loadUrl( + String url, { + String? title, + bool? autoPlay, + double? aspectRatio, + bool? isLive, + String? placeholder, + String? artist, + }) { + if (_chewieController != null) { + _chewieController!.dispose(); + _controller!.dispose(); + } + + _controller = VideoPlayerController.networkUrl( + Uri.parse(url), + httpHeaders: Map.from({"user-agent": userAgent}), + ); + _chewieController = ChewieController( + videoPlayerController: _controller!, + autoPlay: autoPlay ?? true, + aspectRatio: aspectRatio, + isLive: isLive ?? false, + autoInitialize: true, + allowedScreenSleep: false, + placeholder: + (placeholder?.isNotEmpty ?? false) + ? ProxyImg(url: placeholder!) + : null, + ); + + // insert media item + mediaItem.add( + MediaItem( + id: url.hashCode.toString(), + title: title ?? url, + artist: artist, + isLive: _chewieController!.isLive, + artUri: + (placeholder?.isNotEmpty ?? false) ? Uri.parse(placeholder!) : null, + ), + ); + _chewieController!.videoPlayerController.addListener(updatePlayerState); + } + + void updatePlayerState() { + final isPlaying = + _chewieController?.videoPlayerController.value.isPlaying ?? false; + + playbackState.add( + playbackState.value.copyWith( + controls: [ + if (playbackState.value.playing) + MediaControl.pause + else + MediaControl.play, + MediaControl.stop, + ], + playing: isPlaying, + processingState: switch (_chewieController + ?.videoPlayerController + .value + .isInitialized) { + true => AudioProcessingState.ready, + false => AudioProcessingState.idle, + _ => AudioProcessingState.completed, + }, + ), + ); + } +} diff --git a/lib/widgets/video_player_main.dart b/lib/widgets/video_player_main.dart new file mode 100644 index 0000000..e3e6e3f --- /dev/null +++ b/lib/widgets/video_player_main.dart @@ -0,0 +1,53 @@ +import 'package:chewie/chewie.dart'; +import 'package:flutter/widgets.dart'; +import 'package:zap_stream_flutter/main.dart'; + +class MainVideoPlayerWidget extends StatefulWidget { + final String url; + final String? title; + final String? placeholder; + final double? aspectRatio; + final bool? autoPlay; + final bool? isLive; + + const MainVideoPlayerWidget({ + super.key, + required this.url, + this.title, + this.placeholder, + this.aspectRatio, + this.autoPlay, + this.isLive, + }); + + @override + State createState() => _MainVideoPlayerWidget(); +} + +class _MainVideoPlayerWidget extends State { + @override + void initState() { + mainPlayer.loadUrl( + widget.url, + title: widget.title, + placeholder: widget.placeholder, + aspectRatio: widget.aspectRatio, + autoPlay: widget.autoPlay, + isLive: widget.isLive, + artist: "zap.stream" + ); + + super.initState(); + } + + @override + void dispose() { + mainPlayer.stop(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Chewie(controller: mainPlayer.chewie!); + } +} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 172e45c..a785188 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,8 @@ import FlutterMacOS import Foundation +import audio_service +import audio_session import emoji_picker_flutter import file_selector_macos import firebase_core @@ -23,6 +25,8 @@ import video_player_avfoundation import wakelock_plus func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AudioServicePlugin.register(with: registry.registrar(forPlugin: "AudioServicePlugin")) + AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin")) EmojiPickerFlutterPlugin.register(with: registry.registrar(forPlugin: "EmojiPickerFlutterPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) diff --git a/pubspec.lock b/pubspec.lock index 7a734e2..9a1dcd4 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -49,6 +49,38 @@ packages: url: "https://pub.dev" source: hosted version: "2.12.0" + audio_service: + dependency: "direct main" + description: + name: audio_service + sha256: cb122c7c2639d2a992421ef96b67948ad88c5221da3365ccef1031393a76e044 + url: "https://pub.dev" + source: hosted + version: "0.18.18" + audio_service_platform_interface: + dependency: transitive + description: + name: audio_service_platform_interface + sha256: "6283782851f6c8b501b60904a32fc7199dc631172da0629d7301e66f672ab777" + url: "https://pub.dev" + source: hosted + version: "0.1.3" + audio_service_web: + dependency: transitive + description: + name: audio_service_web + sha256: b8ea9243201ee53383157fbccf13d5d2a866b5dda922ec19d866d1d5d70424df + url: "https://pub.dev" + source: hosted + version: "0.1.4" + audio_session: + dependency: transitive + description: + name: audio_session + sha256: "8f96a7fecbb718cb093070f868b4cdcb8a9b1053dce342ff8ab2fde10eb9afb7" + url: "https://pub.dev" + source: hosted + version: "0.2.2" bech32: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index b895e26..8d25e2e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -23,10 +23,10 @@ dependencies: crypto: ^3.0.6 convert: ^3.1.2 collection: ^1.19.1 - video_player: ^2.9.5 clipboard: ^0.1.3 qr_flutter: ^4.1.0 url_launcher: ^6.3.1 + video_player: ^2.9.5 chewie: ^1.11.3 image_picker: ^1.1.2 emoji_picker_flutter: ^4.3.0 @@ -45,6 +45,7 @@ dependencies: flutter_local_notifications: ^19.2.1 flutter_dotenv: ^5.2.1 protocol_handler: ^0.2.0 + audio_service: ^0.18.18 dependency_overrides: ndk: