diff --git a/client/android/app/src/main/AndroidManifest.xml b/client/android/app/src/main/AndroidManifest.xml
index 29ebfbb35..04dd59fd9 100644
--- a/client/android/app/src/main/AndroidManifest.xml
+++ b/client/android/app/src/main/AndroidManifest.xml
@@ -1,4 +1,5 @@
-
+
@@ -28,7 +32,7 @@
android:name="io.flutter.embedding.android.EnableImpeller"
android:value="false"/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
\ No newline at end of file
+
diff --git a/packages/flet_video/lib/src/video.dart b/packages/flet_video/lib/src/video.dart
index f2f886926..7fafdab39 100644
--- a/packages/flet_video/lib/src/video.dart
+++ b/packages/flet_video/lib/src/video.dart
@@ -5,6 +5,9 @@ import 'package:flet/flet.dart';
import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
+import 'package:audio_service/audio_service.dart';
+import 'package:rxdart/rxdart.dart';
+
import 'utils/video.dart';
@@ -24,6 +27,10 @@ class VideoControl extends StatefulWidget {
}
class _VideoControlState extends State with FletStoreMixin {
+ int _lastProcessedIndex = -1;
+ Duration _lastEmittedPosition = Duration.zero;
+ bool _trackChange = false;
+
late final playerConfig = PlayerConfiguration(
title: widget.control.attrString("title", "Flet Video")!,
muted: widget.control.attrBool("muted", false)!,
@@ -38,16 +45,35 @@ class _VideoControlState extends State with FletStoreMixin {
late final Player player = Player(
configuration: playerConfig,
);
+ late final AudioHandler _audioHandler;
+
late final videoControllerConfiguration = parseControllerConfiguration(
widget.control, "configuration", const VideoControllerConfiguration())!;
late final controller =
VideoController(player, configuration: videoControllerConfiguration);
+
@override
void initState() {
super.initState();
+
+ AudioService.init(
+ builder: () => MyAudioHandler(player),
+ config: const AudioServiceConfig(
+ androidNotificationChannelId: 'com.appveyor.flet.channel.audio',
+ androidNotificationChannelName: 'Audio playback',
+ androidNotificationOngoing: true,
+ ),
+ ).then((handler) {
+ _audioHandler = handler;
+ // Now you can use _audioHandler for audio service operations
+ }).catchError((error) {
+ // Handle errors during initialization
+ debugPrint('Error initializing AudioService: $error');
+ });
player.open(Playlist(parseVideoMedia(widget.control, "playlist")),
play: widget.control.attrBool("autoPlay", false)!);
+
}
@override
@@ -74,6 +100,19 @@ class _VideoControlState extends State with FletStoreMixin {
.triggerControlEvent(widget.control.id, "track_changed", message ?? "");
}
+ void _onPositionChanged(Duration position, Duration duration, Duration buffer, int percent) {
+ // commenting out, may be too verbose to display every 1 second
+ // debugPrint("New Position is ${position} seconds, duration is ${duration} seconds, buffer is ${buffer} seconds, and percent done ${percent}%");
+ final data = {
+ "position": position.inSeconds, // Send position in seconds
+ "duration": duration.inSeconds, // Send duration in seconds
+ "buffer": buffer.inSeconds, // Send buffer in seconds
+ "percent": percent,
+ };
+ widget.backend.triggerControlEvent(widget.control.id, "positionChanged", jsonEncode(data));
+ }
+
+
@override
Widget build(BuildContext context) {
debugPrint("Video build: ${widget.control.id}");
@@ -105,6 +144,9 @@ class _VideoControlState extends State with FletStoreMixin {
bool onError = widget.control.attrBool("onError", false)!;
bool onCompleted = widget.control.attrBool("onCompleted", false)!;
bool onTrackChanged = widget.control.attrBool("onTrackChanged", false)!;
+ bool onPositionChanged = widget.control.attrBool("onPositionChanged", false)!;
+ int throttle = widget.control.attrInt("throttle", 1000)!;
+
double? volume = widget.control.attrDouble("volume");
double? pitch = widget.control.attrDouble("pitch");
@@ -200,34 +242,34 @@ class _VideoControlState extends State with FletStoreMixin {
switch (methodName) {
case "play":
debugPrint("Video.play($hashCode)");
- await player.play();
+ await _audioHandler.play();
break;
case "pause":
debugPrint("Video.pause($hashCode)");
- await player.pause();
+ await _audioHandler.pause();
break;
case "play_or_pause":
debugPrint("Video.playOrPause($hashCode)");
- await player.playOrPause();
+ await _audioHandler.play();
break;
case "stop":
debugPrint("Video.stop($hashCode)");
- await player.stop();
+ await _audioHandler.stop();
player.open(Playlist(parseVideoMedia(widget.control, "playlist")),
play: false);
break;
case "seek":
debugPrint("Video.jump($hashCode)");
- await player.seek(Duration(
+ await _audioHandler.seek(Duration(
milliseconds: int.tryParse(args["position"] ?? "") ?? 0));
break;
case "next":
debugPrint("Video.next($hashCode)");
- await player.next();
+ await _audioHandler.skipToNext();
break;
case "previous":
debugPrint("Video.previous($hashCode)");
- await player.previous();
+ await _audioHandler.skipToPrevious();
break;
case "jump_to":
debugPrint("Video.jump($hashCode)");
@@ -278,14 +320,180 @@ class _VideoControlState extends State with FletStoreMixin {
_onCompleted(event.toString());
}
});
+ // Send position, duration, buffer and percent to Flet.
+ // Former 3 are in seconds, percent is percentage 0-100% of track completed
+ // Throttling defaults to 1000ms(1 second) unless overridden, as well as no updates
+ // till seconds change to not overload flet socket.
+ player.stream.position.throttleTime(Duration(milliseconds: throttle)).listen((position) {
+ if (position != Duration.zero) { // this ensures duration is updated as track changes
+ if(_trackChange) {
+ // Duration finally changed, update notification for 2nd time with correct duration
+ _audioHandler.customAction('update_notification', {'index': player.state.playlist.index});
+ _trackChange = false;
+ }
+ if (onPositionChanged && position.inSeconds != _lastEmittedPosition.inSeconds) {
+ try {
+ final Duration duration = player.state.duration;
+ final Duration buffer = player.state.buffer;
+ final int percent = (position.inMilliseconds / duration.inMilliseconds * 100).toInt();
+ _lastEmittedPosition = position;
+ _onPositionChanged(position, duration, buffer, percent);
+ } catch (e) {
+ debugPrint("Error in OnPositionChanged: $e");
+ }
+ }
+ }
+ });
player.stream.playlist.listen((event) {
- if (onTrackChanged) {
- _onTrackChanged(event.index.toString());
+ if (event.index != _lastProcessedIndex) { // prevent duplicates
+ _lastProcessedIndex = event.index;
+ if (onTrackChanged) {
+ _onTrackChanged(event.index.toString());
+ }
+ _audioHandler.customAction('update_notification', {'index': event.index});
+ // There is a race condition here, just because the track changed, does not mean duration
+ // has been updated yet, but we don't want to delay sending Flet track change either
+ // so it can update it's UI quickly. A stream on duration won't work because if 2 songs
+ // have same duration that won't trigger. Best way to handle this then is update notification
+ // one more time when duration does change, by letting position stream handle it.
+ _trackChange = true;
}
});
return constrainedControl(context, video, widget.parent, widget.control);
});
}
+}
+
+class MyAudioHandler extends BaseAudioHandler {
+ final Player player;
+ MyAudioHandler(this.player);
+ final PlaybackState _basePlaybackState = PlaybackState(
+ controls: [
+ MediaControl.skipToPrevious,
+ MediaControl.play,
+ MediaControl.pause,
+ MediaControl.skipToNext,
+ ],
+ systemActions: const {
+ MediaAction.seek,
+ },
+ processingState: AudioProcessingState.ready,
+ );
+
+ @override
+ Future customAction(String name, [Map? extras]) async {
+ if (name == 'update_notification') {
+ final index = extras?['index'] as int?;
+ if (index != null) {
+ await updatePlaybackState(index);
+ }
+ } else {
+ debugPrint("Unknown custom action: $name");
+ }
+ }
+
+ @override
+ Future play() async {
+ try {
+ await player.playOrPause();
+ // we need to trigger updatePlaybackState if first time hitting play
+ // or seek on notification bar will not work correctly
+ if (player.state.position.inMilliseconds == 0) {
+ updatePlaybackState(player.state.playlist.index);
+ } else {
+ playbackState.add(_basePlaybackState.copyWith(playing: player.state.playing, updatePosition: player.state.position));
+ }
+ } catch (e) {
+ debugPrint("Playback error: ${e}");
+ }
+ }
+
+ @override
+ Future pause() async {
+ try {
+ await player.playOrPause();
+ playbackState.add(_basePlaybackState.copyWith(playing: player.state.playing, updatePosition: player.state.position));
+ } catch (e) {
+ debugPrint("Playback error: ${e}");
+ }
+ }
+
+ @override
+ Future stop() async {
+ try {
+ await player.stop();
+ playbackState.add(_basePlaybackState.copyWith(playing: player.state.playing, updatePosition: player.state.position));
+ } catch (e) {
+ debugPrint("Playback error: ${e}");
+ }
+ }
+ // Because these 2 functions are changing songs, no need to call
+ // playbackState as track change will trigger player.stream.playlist.listen
+ // to call updatePlaybackState() to do it for us, still required for
+ // bluetooth etc devices to work correctly
+ @override
+ Future skipToNext() async {
+ try {
+ await player.next();
+ } catch (e) {
+ debugPrint("Playback error: ${e}");
+ }
+ }
+
+ @override
+ Future skipToPrevious() async {
+ try {
+ await player.previous();
+ } catch (e) {
+ debugPrint("Playback error: ${e}");
+ }
+ }
+
+ @override
+ Future seek(Duration position) async {
+ try {
+ await player.seek(position);
+ playbackState.add(_basePlaybackState.copyWith(playing: player.state.playing, updatePosition: position));
+ } catch (e) {
+ debugPrint("Playback error: ${e}");
+ }
+ }
+
+ Future updatePlaybackState(int index) async {
+ final currentMedia = player.state.playlist.medias[index];
+ final extras = currentMedia.extras;
+ // Url Decode and extract filename
+ // I am going to assume structure of https://blah.com/Artist - SongName.mp3
+ // Let's attempt to split their filename into title/artist if they did not
+ // include extras { title and artist } as full http url would be ugly
+
+ final filename = Uri.decodeFull(currentMedia.uri.split('/').last);
+ final parts = filename.split('.');
+ final filenameWithoutExtension = parts.sublist(0, parts.length - 1).join('.');
+ final artistAndTitle = filenameWithoutExtension.split('-');
+ final title = extras?['title'] ?? artistAndTitle.last.trim();
+ final artist = extras?['artist'] ?? artistAndTitle.sublist(0, artistAndTitle.length - 1).join('-').trim();
+ final artUri = extras?['artUri'] as String?;
+ final album = extras?['album'] as String?;
+ final genre = extras?['genre'] as String?;
+ final displayTitle = extras?['displayTitle'] as String?;
+ final displaySubtitle = extras?['displaySubtitle'] as String?;
+ final displayDescription = extras?['displayDescription'] as String?;
+
+ mediaItem.add(MediaItem(
+ id: index.toString(),
+ title: title,
+ artist: artist,
+ artUri: artUri != null ? Uri.parse(artUri) : null,
+ album: album != null ? album : null,
+ genre: genre != null ? genre : null,
+ displayTitle: displayTitle != null ? displayTitle : null,
+ displaySubtitle: displaySubtitle != null ? displaySubtitle : null,
+ displayDescription: displayDescription != null ? displayDescription : null,
+ duration: player.state.duration,
+ ));
+ playbackState.add(_basePlaybackState.copyWith(playing: player.state.playing, updatePosition: player.state.position));
+ }
}
\ No newline at end of file
diff --git a/packages/flet_video/pubspec.yaml b/packages/flet_video/pubspec.yaml
index 95d0e479d..1c6c6660b 100644
--- a/packages/flet_video/pubspec.yaml
+++ b/packages/flet_video/pubspec.yaml
@@ -16,6 +16,8 @@ dependencies:
media_kit: ^1.1.10
media_kit_video: ^1.2.4
media_kit_libs_video: ^1.0.4
+ audio_service: ^0.18.15
+ rxdart: ^0.28.0
flet:
path: ../flet/
diff --git a/sdk/python/packages/flet/src/flet/core/video.py b/sdk/python/packages/flet/src/flet/core/video.py
index 5f93717df..e3dde735c 100644
--- a/sdk/python/packages/flet/src/flet/core/video.py
+++ b/sdk/python/packages/flet/src/flet/core/video.py
@@ -1,4 +1,5 @@
import dataclasses
+import json
from enum import Enum
from typing import Any, Dict, List, Optional, Union, cast
@@ -8,6 +9,8 @@
from flet.core.box import FilterQuality
from flet.core.constrained_control import ConstrainedControl
from flet.core.control import OptionalNumber
+from flet.core.control_event import ControlEvent
+from flet.core.event_handler import EventHandler
from flet.core.ref import Ref
from flet.core.text_style import TextStyle
from flet.core.tooltip import TooltipValue
@@ -58,6 +61,15 @@ class VideoSubtitleConfiguration:
padding: Optional[PaddingValue] = dataclasses.field(default=None)
visible: Optional[bool] = dataclasses.field(default=None)
+class VideoPositionChangedEvent(ControlEvent):
+ def __init__(self, e: ControlEvent):
+ super().__init__(e.target, e.name, e.data, e.control, e.page)
+ d = json.loads(e.data)
+ self.position: int = d.get("position")
+ self.duration: int = d.get("duration")
+ self.buffer: int = d.get("buffer")
+ self.percent: int = d.get("percent")
+
class Video(ConstrainedControl):
"""
@@ -81,6 +93,7 @@ def __init__(
playlist_mode: Optional[PlaylistMode] = None,
shuffle_playlist: Optional[bool] = None,
volume: OptionalNumber = None,
+ throttle: Optional[int] = None,
playback_rate: OptionalNumber = None,
alignment: Optional[Alignment] = None,
filter_quality: Optional[FilterQuality] = None,
@@ -96,6 +109,7 @@ def __init__(
on_error: OptionalControlEventCallable = None,
on_completed: OptionalControlEventCallable = None,
on_track_changed: OptionalControlEventCallable = None,
+ on_position_changed: OptionalControlEventCallable = None,
#
# ConstrainedControl
#
@@ -164,6 +178,7 @@ def __init__(
self.pitch = pitch
self.fill_color = fill_color
self.volume = volume
+ self.throttle = throttle
self.playback_rate = playback_rate
self.alignment = alignment
self.wakelock = wakelock
@@ -182,6 +197,13 @@ def __init__(
self.on_error = on_error
self.on_completed = on_completed
self.on_track_changed = on_track_changed
+ self.__on_position_changed = EventHandler(
+ lambda e: VideoPositionChangedEvent(e)
+ )
+ self._add_event_handler(
+ "positionChanged", self.__on_position_changed.get_handler()
+ )
+ self.on_position_changed = on_position_changed
def _get_control_name(self):
return "video"
@@ -416,6 +438,15 @@ def volume(self, value: OptionalNumber):
assert value is None or 0 <= value <= 100, "volume must be between 0 and 100"
self._set_attr("volume", value)
+ # throttle
+ @property
+ def throttle(self) -> Optional[int]:
+ return self._get_attr("throttle", data_type="int")
+
+ @throttle.setter
+ def throttle(self, value: Optional[int]):
+ self._set_attr("throttle", value)
+
# playback_rate
@property
def playback_rate(self) -> OptionalNumber:
@@ -550,3 +581,13 @@ def on_track_changed(self) -> OptionalControlEventCallable:
def on_track_changed(self, handler: OptionalControlEventCallable):
self._set_attr("onTrackChanged", True if handler is not None else None)
self._add_event_handler("track_changed", handler)
+
+ # on_position_changed
+ @property
+ def on_position_changed(self,) -> OptionalEventCallable[VideoPositionChangedEvent]:
+ return self.__on_position_changed.handler
+
+ @on_position_changed.setter
+ def on_position_changed(self, handler: OptionalEventCallable[VideoPositionChangedEvent]):
+ self.__on_position_changed.handler = handler
+ self._set_attr("onPositionChanged", True if handler is not None else None)