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.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 {

View File

@ -3,6 +3,8 @@
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<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.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
</manifest>

View File

@ -1,52 +1,73 @@
<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
android:label="zap.stream"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/ic_launcher"
android:label="zap.stream">
<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:hardwareAccelerated="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
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
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<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" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="zswc" />
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<service
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
android:name="flutterEmbedding"
android:value="2" />
</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:
https://developer.android.com/training/package-visibility and
@ -55,10 +76,11 @@
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
<action android:name="android.intent.action.PROCESS_TEXT" />
<data android:mimeType="text/plain" />
</intent>
</queries>
<meta-data
android:name="firebase_messaging_auto_init_enabled"
android:value="false" />

View File

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

View File

@ -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(

View File

@ -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<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
@ -20,5 +24,14 @@ Future<void> 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();
}

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

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

View File

@ -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:

View File

@ -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: