feat: background playback (wip)

https://github.com/ryanheise/audio_service/issues/1124
This commit is contained in:
2025-05-28 16:28:24 +01:00
parent 5789d9a7a1
commit fb4821ffdd
12 changed files with 263 additions and 32 deletions

View File

@ -1,5 +1,4 @@
import com.android.build.api.dsl.ApkSigningConfig import com.android.build.api.dsl.ApkSigningConfig
import com.android.build.api.dsl.SigningConfig
import org.jetbrains.kotlin.gradle.targets.js.toHex import org.jetbrains.kotlin.gradle.targets.js.toHex
import java.io.FileInputStream import java.io.FileInputStream
import java.util.Base64 import java.util.Base64
@ -8,11 +7,9 @@ import java.util.Properties
plugins { plugins {
id("com.android.application") id("com.android.application")
// START: FlutterFire Configuration
id("com.google.gms.google-services")
// END: FlutterFire Configuration
id("kotlin-android") id("kotlin-android")
id("dev.flutter.flutter-gradle-plugin") id("dev.flutter.flutter-gradle-plugin")
id("com.google.gms.google-services")
} }
fun getKeystoreFile(base64String: String?, hash: String, fileName: String): File { fun getKeystoreFile(base64String: String?, hash: String, fileName: String): File {

View File

@ -5,4 +5,6 @@
--> -->
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
</manifest> </manifest>

View File

@ -1,53 +1,74 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<application <application
android:label="zap.stream"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher"
android:label="zap.stream">
<activity <activity
android:name=".MainActivity" android:name="com.ryanheise.audioservice.AudioServiceActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:exported="true" android:exported="true"
android:hardwareAccelerated="true"
android:launchMode="singleTop" android:launchMode="singleTop"
android:taskAffinity="" android:taskAffinity=""
android:theme="@style/LaunchTheme" android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data <meta-data
android:name="io.flutter.embedding.android.NormalTheme" android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" android:resource="@style/NormalTheme" />
/>
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<intent-filter android:autoVerify="true"> <intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" android:host="zap.stream" />
<data android:host="zap.stream" />
<data android:scheme="http" />
<data android:scheme="https" /> <data android:scheme="https" />
</intent-filter> </intent-filter>
<intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="zswc" /> <data android:scheme="zswc" />
</intent-filter> </intent-filter>
</activity> </activity>
<!-- Don't delete the meta-data below. <service
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> android:name="com.ryanheise.audioservice.AudioService"
android:exported="true"
android:foregroundServiceType="mediaPlayback"
android:permission="android.permission.FOREGROUND_SERVICE"
tools:ignore="Instantiatable">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
<receiver
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
android:exported="true"
tools:ignore="Instantiatable">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> android:value="2" />
</application> </application>
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Required to query activities that can process text, see: <!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT. https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
@ -59,6 +80,7 @@
<data android:mimeType="text/plain" /> <data android:mimeType="text/plain" />
</intent> </intent>
</queries> </queries>
<meta-data <meta-data
android:name="firebase_messaging_auto_init_enabled" android:name="firebase_messaging_auto_init_enabled"
android:value="false" /> android:value="false" />

View File

@ -72,6 +72,7 @@
<array> <array>
<string>fetch</string> <string>fetch</string>
<string>remote-notification</string> <string>remote-notification</string>
<string>audio</string>
</array> </array>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>
<string>LaunchScreen</string> <string>LaunchScreen</string>

View File

@ -25,7 +25,11 @@ void runZapStream() {
supportedLocales: AppLocaleUtils.supportedLocales, supportedLocales: AppLocaleUtils.supportedLocales,
localizationsDelegates: GlobalMaterialLocalizations.delegates, localizationsDelegates: GlobalMaterialLocalizations.delegates,
theme: ThemeData.localize( theme: ThemeData.localize(
ThemeData(colorScheme: ColorScheme.dark(), highlightColor: PRIMARY_1), ThemeData(
colorScheme: ColorScheme.dark(),
highlightColor: PRIMARY_1,
useMaterial3: true,
),
TextTheme(), TextTheme(),
), ),
routerConfig: GoRouter( routerConfig: GoRouter(

View File

@ -1,5 +1,6 @@
import 'dart:developer' as developer; import 'dart:developer' as developer;
import 'package:audio_service/audio_service.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.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/const.dart';
import 'package:zap_stream_flutter/i18n/strings.g.dart'; import 'package:zap_stream_flutter/i18n/strings.g.dart';
import 'package:zap_stream_flutter/notifications.dart'; import 'package:zap_stream_flutter/notifications.dart';
import 'package:zap_stream_flutter/player.dart';
late final MainPlayer mainPlayer;
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@ -20,5 +24,14 @@ Future<void> main() async {
developer.log("Failed to setup notifications: $e"); 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(); runZapStream();
} }

View File

@ -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/pill.dart';
import 'package:zap_stream_flutter/widgets/profile.dart'; import 'package:zap_stream_flutter/widgets/profile.dart';
import 'package:zap_stream_flutter/widgets/stream_info.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'; import 'package:zap_stream_flutter/widgets/zap.dart';
class StreamPage extends StatefulWidget { class StreamPage extends StatefulWidget {
@ -141,11 +141,13 @@ class _StreamPage extends State<StreamPage> with RouteAware {
aspectRatio: 16 / 9, aspectRatio: 16 / 9,
child: child:
(stream.info.stream != null && !_offScreen) (stream.info.stream != null && !_offScreen)
? VideoPlayerWidget( ? MainVideoPlayerWidget(
url: stream.info.stream!, url: stream.info.stream!,
placeholder: stream.info.image, placeholder: stream.info.image,
aspectRatio: 16 / 9, aspectRatio: 16 / 9,
isLive: true, isLive: true,
title: stream.info.title,
) )
: (stream.info.image?.isNotEmpty ?? false) : (stream.info.image?.isNotEmpty ?? false)
? ProxyImg(url: stream.info.image) ? ProxyImg(url: stream.info.image)

100
lib/player.dart Normal file
View File

@ -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<void> play() async {
await _chewieController?.play();
}
@override
Future<void> pause() async {
await _chewieController?.pause();
}
@override
Future<void> 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,
},
),
);
}
}

View File

@ -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<StatefulWidget> createState() => _MainVideoPlayerWidget();
}
class _MainVideoPlayerWidget extends State<MainVideoPlayerWidget> {
@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!);
}
}

View File

@ -5,6 +5,8 @@
import FlutterMacOS import FlutterMacOS
import Foundation import Foundation
import audio_service
import audio_session
import emoji_picker_flutter import emoji_picker_flutter
import file_selector_macos import file_selector_macos
import firebase_core import firebase_core
@ -23,6 +25,8 @@ import video_player_avfoundation
import wakelock_plus import wakelock_plus
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { 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")) EmojiPickerFlutterPlugin.register(with: registry.registrar(forPlugin: "EmojiPickerFlutterPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))

View File

@ -49,6 +49,38 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.12.0" 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: bech32:
dependency: "direct main" dependency: "direct main"
description: description:

View File

@ -23,10 +23,10 @@ dependencies:
crypto: ^3.0.6 crypto: ^3.0.6
convert: ^3.1.2 convert: ^3.1.2
collection: ^1.19.1 collection: ^1.19.1
video_player: ^2.9.5
clipboard: ^0.1.3 clipboard: ^0.1.3
qr_flutter: ^4.1.0 qr_flutter: ^4.1.0
url_launcher: ^6.3.1 url_launcher: ^6.3.1
video_player: ^2.9.5
chewie: ^1.11.3 chewie: ^1.11.3
image_picker: ^1.1.2 image_picker: ^1.1.2
emoji_picker_flutter: ^4.3.0 emoji_picker_flutter: ^4.3.0
@ -45,6 +45,7 @@ dependencies:
flutter_local_notifications: ^19.2.1 flutter_local_notifications: ^19.2.1
flutter_dotenv: ^5.2.1 flutter_dotenv: ^5.2.1
protocol_handler: ^0.2.0 protocol_handler: ^0.2.0
audio_service: ^0.18.18
dependency_overrides: dependency_overrides:
ndk: ndk: