Initial commit

This commit is contained in:
2024-12-15 00:52:28 +01:00
commit 540b504c2e
33 changed files with 2217 additions and 0 deletions

153
lib/api/commands.dart Normal file
View File

@@ -0,0 +1,153 @@
import 'dart:convert';
sealed class ConnectionEvent {}
class Connect extends ConnectionEvent {
final String uri;
Connect(this.uri);
}
class Disconnect extends ConnectionEvent {}
class Command extends ConnectionEvent {
final String type;
final Map<String, dynamic> value;
Command({
required this.type,
required this.value,
});
factory Command.fromJson(Map<String, dynamic> json) {
return Command(
type: json['type'],
value: json['value'],
);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> result = Map.from(value);
result['type'] = type;
return result;
}
String toJsonString() {
return jsonEncode(toJson());
}
factory Command.subscribe(String property) {
return Command(
type: 'subscribe',
value: {
'property': property,
},
);
}
factory Command.unsubscribeAll() {
return Command(
type: 'unsubscribe_all',
value: {},
);
}
factory Command.load(String url) {
return Command(
type: 'load',
value: {
'url': url,
},
);
}
factory Command.togglePlayback() {
return Command(
type: 'toggle_playback',
value: {},
);
}
factory Command.volume(double volume) {
return Command(
type: 'volume',
value: {
'volume': volume,
},
);
}
factory Command.time(double time) {
return Command(
type: 'time',
value: {
'time': time,
},
);
}
factory Command.playlistNext() {
return Command(
type: 'playlist_next',
value: {},
);
}
factory Command.playlistPrevious() {
return Command(
type: 'playlist_previous',
value: {},
);
}
factory Command.playlistGoto(int position) {
return Command(
type: 'playlist_goto',
value: {
'position': position,
},
);
}
factory Command.playlistClear() {
return Command(
type: 'playlist_clear',
value: {},
);
}
factory Command.playlistRemove(int position) {
return Command(
type: 'playlist_remove',
value: {
'position': position,
},
);
}
factory Command.playlistMove(int from, int to) {
return Command(
type: 'playlist_move',
value: {
'from': from,
'to': to,
},
);
}
factory Command.shuffle() {
return Command(
type: 'shuffle',
value: {},
);
}
factory Command.setLooping(bool value) {
return Command(
type: 'set_looping',
value: {
'value': value,
},
);
}
}

206
lib/api/events.dart Normal file
View File

@@ -0,0 +1,206 @@
// TODO: handle typing and deserialization of events
import 'package:flutter/foundation.dart';
import '../state/player_state.dart';
// NOTE: see DEFAULT_PROPERTY_SUBSCRIPTIONS in the websocket API source for greg-ng.
@immutable
sealed class Event {
const Event();
}
@immutable
class ClearPlayerState extends Event {
const ClearPlayerState() : super();
}
@immutable
class InitialPlayerState extends Event {
final PlayerState playerState;
const InitialPlayerState(this.playerState) : super();
factory InitialPlayerState.fromJson(dynamic json) {
return InitialPlayerState(PlayerState.fromJson(json));
}
}
@immutable
sealed class PropertyChangedEvent extends Event {
final bool local;
const PropertyChangedEvent({this.local = false}) : super();
}
@immutable
class PlaylistChange extends PropertyChangedEvent {
final Playlist playlist;
const PlaylistChange(this.playlist, { super.local }) : super();
factory PlaylistChange.fromJson(dynamic json) {
return PlaylistChange(
(json as List).map((e) => PlaylistItem.fromJson(e)).toList(),
);
}
}
@immutable
class LoopPlaylistChange extends PropertyChangedEvent {
final bool isLooping;
const LoopPlaylistChange(this.isLooping, { super.local }) : super();
factory LoopPlaylistChange.fromJson(dynamic json) {
return LoopPlaylistChange(json == "inf");
}
}
@immutable
class PercentPositionChange extends PropertyChangedEvent {
final double currentPercentPosition;
const PercentPositionChange(this.currentPercentPosition, { super.local }) : super();
factory PercentPositionChange.fromJson(dynamic json) {
return PercentPositionChange(json ?? 0.0);
}
}
@immutable
class VolumeChange extends PropertyChangedEvent {
final double volume;
const VolumeChange(this.volume, { super.local }) : super();
factory VolumeChange.fromJson(dynamic json) {
return VolumeChange(json);
}
}
@immutable
class DurationChange extends PropertyChangedEvent {
final Duration duration;
const DurationChange(this.duration, { super.local }) : super();
factory DurationChange.fromJson(dynamic json) {
return DurationChange(Duration(milliseconds: ((json ?? 0.0) * 1000).round()));
}
}
@immutable
class PauseChange extends PropertyChangedEvent {
final bool isPaused;
const PauseChange(this.isPaused, { super.local }) : super();
factory PauseChange.fromJson(dynamic json) {
return PauseChange(json as bool);
}
}
@immutable
class MuteChange extends PropertyChangedEvent {
final bool isMuted;
const MuteChange(this.isMuted, { super.local }) : super();
factory MuteChange.fromJson(dynamic json) {
return MuteChange(json as bool);
}
}
@immutable
class TrackListChange extends PropertyChangedEvent {
final List<SubtitleTrack> tracks;
const TrackListChange(this.tracks, { super.local }) : super();
factory TrackListChange.fromJson(dynamic json) {
final trackList = json as List;
trackList.retainWhere((e) => e is Map && e['type'] == 'sub');
return TrackListChange(
trackList.map((e) => SubtitleTrack.fromJson(e)).toList(),
);
}
}
@immutable
class DemuxerCacheStateChange extends PropertyChangedEvent {
final double cachedTimestamp;
const DemuxerCacheStateChange(this.cachedTimestamp, { super.local }) : super();
factory DemuxerCacheStateChange.fromJson(dynamic json) {
final demuxerCacheState = json as Map?;
final cachedTimestamp =
demuxerCacheState != null ? demuxerCacheState['cache-end'] ?? 0.0 : 0.0;
return DemuxerCacheStateChange(cachedTimestamp);
}
}
@immutable
class PausedForCacheChange extends PropertyChangedEvent {
final bool isPausedForCache;
const PausedForCacheChange(this.isPausedForCache, { super.local }) : super();
factory PausedForCacheChange.fromJson(dynamic json) {
return PausedForCacheChange(json as bool? ?? false);
}
}
// @immutable
// class ChapterListChange extends PropertyChangedEvent {
// final List<Chapter> chapters;
// ChapterListChange(this.chapters);
// factory ChapterListChange.fromJson(dynamic json) {
// return ChapterListChange(
// (json as List).map((e) => Chapter.fromJson(e)).toList(),
// );
// }
// }
Event? parseEvent(dynamic value) {
if (value is String) {
return null;
}
if (value is Map && value.containsKey('property-change')) {
final propertyChange = value['property-change'];
switch (propertyChange['name']) {
case 'playlist':
return PlaylistChange.fromJson(propertyChange['data']);
case 'loop-playlist':
return LoopPlaylistChange.fromJson(propertyChange['data']);
case 'percent-pos':
return PercentPositionChange.fromJson(propertyChange['data']);
case 'volume':
return VolumeChange.fromJson(propertyChange['data']);
case 'duration':
return DurationChange.fromJson(propertyChange['data']);
case 'pause':
return PauseChange.fromJson(propertyChange['data']);
case 'mute':
return MuteChange.fromJson(propertyChange['data']);
case 'track-list':
return TrackListChange.fromJson(propertyChange['data']);
case 'demuxer-cache-state':
return DemuxerCacheStateChange.fromJson(propertyChange['data']);
case 'paused-for-cache':
return PausedForCacheChange.fromJson(propertyChange['data']);
// "chapter-list",
// "paused-for-cache",
default:
return null;
}
}
return null;
}

30
lib/main.dart Normal file
View File

@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'state/connection_state_bloc.dart';
import 'state/player_state_bloc.dart';
import 'player_ui.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
final PlayerStateBloc playerStateBloc = PlayerStateBloc();
final ConnectionStateBloc connectionStateBloc =
ConnectionStateBloc(playerStateBloc);
return MaterialApp(
title: 'Gergle',
home: MultiBlocProvider(
providers: [
BlocProvider(create: (_) => connectionStateBloc),
BlocProvider(create: (_) => playerStateBloc),
],
child: PlayerUi(),
),
);
}
}

414
lib/player_ui.dart Normal file
View File

@@ -0,0 +1,414 @@
import 'dart:math' show max;
import 'dart:developer' show log;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:gergle/api/commands.dart';
import 'package:gergle/api/events.dart';
import 'state/connection_state_bloc.dart';
import 'state/player_state_bloc.dart';
import 'state/player_state.dart';
class PlayerUi extends StatelessWidget {
PlayerUi({
super.key,
});
final TextEditingController _textController = TextEditingController();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: _buildAppBar(context),
body: Padding(
padding: const EdgeInsets.all(20),
child: BlocBuilder<PlayerStateBloc, PlayerState?>(
builder: (context, state) {
if (state == null) {
return const Center(
child: CircularProgressIndicator(),
);
}
final playerState = state;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: ReorderableListView.builder(
scrollDirection: Axis.vertical,
itemBuilder: (context, i) =>
_buildPlaylistTile(context, playerState, i),
itemCount: playerState.playlist.length,
onReorder: (from, to) {
BlocProvider.of<ConnectionStateBloc>(context).add(
Command.playlistMove(from, to),
);
if (from < to) {
to -= 1;
}
final item = playerState.playlist.removeAt(from);
playerState.playlist.insert(to, item);
BlocProvider.of<PlayerStateBloc>(context).add(
PlaylistChange(playerState.playlist, local: true));
},
),
),
_buildInputBar(context, playerState),
],
);
},
),
),
bottomNavigationBar: BlocBuilder<PlayerStateBloc, PlayerState?>(
builder: (context, state) {
if (state == null) {
return const SizedBox.shrink();
}
return _buildBottomBar(context, state);
},
),
);
}
AppBar _buildAppBar(BuildContext context) {
return AppBar(
title: const Text('Gergle'),
backgroundColor: Theme.of(context).primaryColor,
actions: [
DropdownMenu(
leadingIcon: const Icon(Icons.storage),
dropdownMenuEntries: const <DropdownMenuEntry<String?>>[
DropdownMenuEntry(
label: 'Georg',
value: 'wss://georg.pvv.ntnu.no/ws',
),
DropdownMenuEntry(
label: 'Brzeczyszczykiewicz',
value: 'wss://brzeczyszczykiewicz.pvv.ntnu.no/ws',
),
if (kDebugMode) ...[
DropdownMenuEntry(
label: 'Local 8009',
value: 'ws://localhost:8009/ws',
),
],
DropdownMenuEntry(
value: null,
label: 'Custom...',
),
],
onSelected: (value) async {
final connectionStateBloc =
BlocProvider.of<ConnectionStateBloc>(context);
value ??= await _askForServerUriMenu(context);
if (value == null) {
return;
}
connectionStateBloc.add(Connect(value));
},
),
IconButton(
icon: const Icon(Icons.settings),
onPressed: () {
throw UnimplementedError();
},
),
if (kDebugMode) ...[
const SizedBox(width: 50),
],
],
);
}
Future<String?> _askForServerUriMenu(BuildContext context) async {
final textController = TextEditingController();
return await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) {
return AlertDialog(
title: const Text('Enter server URI'),
content: TextField(
decoration: const InputDecoration(
labelText: 'Server URI',
),
controller: textController,
onSubmitted: (value) {
Navigator.of(context).pop(value);
},
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
final value = textController.text;
textController.dispose();
Navigator.of(context).pop(value);
},
child: const Text('Connect'),
),
],
);
},
),
);
}
Widget _buildInputBar(BuildContext context, PlayerState playerState) {
return Row(
children: [
Expanded(
child: TextField(
controller: _textController,
decoration: InputDecoration(
labelText: 'Add to playlist',
filled: true,
fillColor: const Color.fromARGB(10, 0, 0, 0),
border: OutlineInputBorder(
borderSide:
BorderSide(color: Theme.of(context).primaryColor, width: 2),
borderRadius: BorderRadius.circular(10),
),
),
onSubmitted: (value) {
BlocProvider.of<ConnectionStateBloc>(context)
.add(Command.load(value));
_textController.clear();
},
),
),
IconButton(
icon: const Icon(Icons.send),
onPressed: () {
BlocProvider.of<ConnectionStateBloc>(context)
.add(Command.load(_textController.text));
_textController.clear();
},
),
IconButton(
icon: const Icon(Icons.playlist_add),
onPressed: () {
// TODO: popup menu for adding multiple links
throw UnimplementedError();
},
),
],
);
}
ListTile _buildPlaylistTile(
BuildContext context,
PlayerState playerState,
int i,
) {
final item = playerState.playlist[i];
return ListTile(
key: ValueKey(item.id),
title: Text(item.title ?? item.filename),
subtitle: Text(item.filename),
selected: item.current,
leading: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
"${i + 1}.",
style: Theme.of(context).textTheme.titleLarge,
),
IconButton(
icon: playerState.isPlaying && item.current
? const Icon(Icons.pause)
: const Icon(Icons.play_arrow),
onPressed: () {
BlocProvider.of<ConnectionStateBloc>(context)
.add(Command.playlistGoto(i));
},
),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.copy),
onPressed: () =>
Clipboard.setData(ClipboardData(text: item.filename)),
),
IconButton(
icon: const Icon(Icons.delete_forever),
color: Colors.redAccent,
onPressed: () {
BlocProvider.of<ConnectionStateBloc>(context)
.add(Command.playlistRemove(i));
},
),
],
),
);
}
static String formatTime(Duration duration) {
final hours = duration.inHours;
final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0');
final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0');
if (hours > 0) {
return '$hours:$minutes:$seconds';
} else {
return '$minutes:$seconds';
}
}
Widget _buildBottomBar(BuildContext context, PlayerState playerState) {
// NOTE: slider throws if the value is over 100, so 99.999 is used to avoid
// hitting the limit with floating point errors.
double cachedPercent = playerState.cachedTimestamp != null
? ((playerState.cachedTimestamp! /
max(playerState.duration.inMilliseconds, 0.00000000000001)) *
1000 *
99.999)
: 0.0;
if (cachedPercent > 100) {
cachedPercent = 0;
}
return BottomAppBar(
child: Row(
children: [
IconButton(
icon: const Icon(Icons.skip_previous),
onPressed: () {
BlocProvider.of<ConnectionStateBloc>(context)
.add(Command.playlistPrevious());
},
),
IconButton(
icon: playerState.isPlaying
? const Icon(Icons.pause)
: const Icon(Icons.play_arrow),
onPressed: () {
BlocProvider.of<ConnectionStateBloc>(context)
.add(Command.togglePlayback());
},
),
IconButton(
icon: const Icon(Icons.skip_next),
onPressed: () {
BlocProvider.of<ConnectionStateBloc>(context)
.add(Command.playlistNext());
},
),
Expanded(
child: Flex(
direction: Axis.horizontal,
children: [
Text(
formatTime(
Duration(
milliseconds: playerState.currentPercentPosition != null
? (playerState.currentPercentPosition! *
playerState.duration.inMilliseconds *
0.01)
.round()
: 0,
),
),
),
// Text(((playerState.currentPercentPosition ?? 0.0) *
// 0.01 *
// playerState.duration)
// .toString()),
Expanded(
flex: 5,
child: Slider(
value: playerState.currentPercentPosition ?? 0,
max: 100.0,
secondaryTrackValue: cachedPercent,
onChanged: (value) {
log('Setting time to $value');
BlocProvider.of<ConnectionStateBloc>(context)
.add(Command.time(value));
},
),
),
Text(formatTime(playerState.duration)),
// TODO: set minimum width for this slider
Expanded(
flex: 1,
child: Slider(
value: playerState.volume,
max: 130.0,
secondaryTrackValue: 100.0,
onChanged: (value) {
BlocProvider.of<ConnectionStateBloc>(context)
.add(Command.volume(value));
},
),
),
Text('${playerState.volume.round()}%'),
],
),
),
// IconButton(
// icon: const Icon(Icons.subtitles),
// onPressed: () {
// throw UnimplementedError();
// },
// ),
PopupMenuButton(
icon: const Icon(Icons.subtitles),
enabled: playerState.subtitleTracks.isNotEmpty,
itemBuilder: (context) {
return playerState.subtitleTracks
.map((track) => PopupMenuItem(
value: track.id,
child: Text(track.title),
))
.toList();
},
onSelected: (value) {
// TODO: add command for changing subtitle track
throw UnimplementedError();
},
),
IconButton(
icon: const Icon(Icons.repeat),
isSelected: playerState.isLooping,
onPressed: () {
BlocProvider.of<ConnectionStateBloc>(context)
.add(Command.setLooping(!playerState.isLooping));
},
),
IconButton(
icon: const Icon(Icons.shuffle),
onPressed: () {
BlocProvider.of<ConnectionStateBloc>(context)
.add(Command.shuffle());
},
),
IconButton(
icon: const Icon(Icons.delete_forever),
onPressed: () {
BlocProvider.of<ConnectionStateBloc>(context)
.add(Command.playlistClear());
},
),
],
),
);
}
}

View File

@@ -0,0 +1,92 @@
import 'dart:convert';
import 'dart:developer' show log;
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import '../api/commands.dart';
import '../api/events.dart';
import 'player_state_bloc.dart';
class ConnectionStateBloc extends Bloc<ConnectionEvent, WebSocketChannel?> {
final PlayerStateBloc playerStateBloc;
String? _uri;
WebSocketChannel? _channel;
ConnectionStateBloc(this.playerStateBloc) : super(null) {
on<Connect>((event, emit) {
if (_channel != null && _uri == event.uri) {
log('Already connected to ${event.uri}');
return;
} else if (_channel != null) {
// Clear connection, and reconnect
state?.sink.close();
playerStateBloc.add(const ClearPlayerState());
emit(null);
}
_uri = event.uri;
_channel = WebSocketChannel.connect(
Uri.parse(event.uri),
);
_channel!.stream.listen(
(event) {
final jsonData = jsonDecode(event as String);
if (jsonData is Map) {
switch (jsonData['type']) {
case 'initial_state':
playerStateBloc.add(
InitialPlayerState.fromJson(jsonData['value']),
);
break;
case 'event':
final event = parseEvent(jsonData['value']);
if (event == null) {
log('Unknown event: ${jsonData['value']}');
} else {
log('Handling event: $event');
playerStateBloc.add(event);
}
break;
default:
log('Unknown message type: ${jsonData['type']}');
log('Message: $jsonData');
break;
}
}
},
onError: (error) {
log('Error: $error');
},
onDone: () {
add(Disconnect());
log('Connection closed, reconnecting...');
add(Connect(_uri!));
},
);
emit(_channel);
});
on<Disconnect>((event, emit) {
_uri = null;
state?.sink.close(0, 'Disconnecting');
playerStateBloc.add(const ClearPlayerState());
emit(null);
});
on<Command>((event, emit) {
if (_channel == null) {
log('Cannot send command when not connected');
return;
}
_channel!.sink.add(event.toJsonString());
});
}
}

144
lib/state/player_state.dart Normal file
View File

@@ -0,0 +1,144 @@
import 'package:flutter/material.dart';
@immutable
class PlayerState {
final List<Chapter> chapters;
final List<SubtitleTrack> subtitleTracks;
final Playlist playlist;
final String currentTrack;
final bool isLooping;
final bool isMuted;
final bool isPlaying;
final bool isPausedForCache;
final double? cachedTimestamp;
final Duration duration;
final double volume;
final double? currentPercentPosition;
const PlayerState({
required this.cachedTimestamp,
required this.chapters,
required this.currentPercentPosition,
required this.currentTrack,
required this.duration,
required this.isLooping,
required this.isMuted,
required this.isPlaying,
required this.isPausedForCache,
required this.playlist,
required this.subtitleTracks,
required this.volume,
});
factory PlayerState.fromJson(Map<String, dynamic> json) {
return PlayerState(
cachedTimestamp: json['cached_timestamp'],
chapters: (json['chapters'] as List).map((e) => Chapter.fromJson(e)).toList(),
currentPercentPosition: json['current_percent_pos'],
currentTrack: json['current_track'],
duration: Duration(milliseconds: (json['duration'] * 1000).round()),
isLooping: json['is_looping'],
isMuted: json['is_muted'],
isPlaying: json['is_playing'],
isPausedForCache: json['is_paused_for_cache'],
playlist: (json['playlist'] as List).map((e) => PlaylistItem.fromJson(e)).toList(),
subtitleTracks: (json['tracks'] as List).map((e) => SubtitleTrack.fromJson(e)).toList(),
volume: json['volume'],
);
}
PlayerState copyWith({
List<Chapter>? chapters,
List<SubtitleTrack>? subtitleTracks,
Playlist? playlist,
String? currentTrack,
bool? isLooping,
bool? isMuted,
bool? isPlaying,
bool? isPausedForCache,
double? cachedTimestamp,
double? currentPercentPosition,
Duration? duration,
double? volume,
}) {
return PlayerState(
cachedTimestamp: cachedTimestamp ?? this.cachedTimestamp,
chapters: chapters ?? this.chapters,
currentPercentPosition: currentPercentPosition ?? this.currentPercentPosition,
currentTrack: currentTrack ?? this.currentTrack,
duration: duration ?? this.duration,
isLooping: isLooping ?? this.isLooping,
isMuted: isMuted ?? this.isMuted,
isPlaying: isPlaying ?? this.isPlaying,
isPausedForCache: isPausedForCache ?? this.isPausedForCache,
playlist: playlist ?? this.playlist,
subtitleTracks: subtitleTracks ?? this.subtitleTracks,
volume: volume ?? this.volume,
);
}
}
typedef Playlist = List<PlaylistItem>;
@immutable
class PlaylistItem {
final bool current;
final String filename;
final int id;
final String? title;
const PlaylistItem({
required this.current,
required this.filename,
required this.id,
required this.title,
});
factory PlaylistItem.fromJson(Map<String, dynamic> json) {
return PlaylistItem(
current: json['current'] ?? false,
filename: json['filename'],
id: json['id'],
title: json['title'],
);
}
}
@immutable
class Chapter {
final String title;
final double time;
const Chapter({
required this.title,
required this.time,
});
factory Chapter.fromJson(Map<String, dynamic> json) {
return Chapter(
title: json['title'],
time: json['time'],
);
}
}
@immutable
class SubtitleTrack {
final int id;
final String title;
final String? lang;
const SubtitleTrack({
required this.id,
required this.title,
required this.lang,
});
factory SubtitleTrack.fromJson(Map<String, dynamic> json) {
return SubtitleTrack(
id: json['id'],
title: json['title'],
lang: json['lang'],
);
}
}

View File

@@ -0,0 +1,78 @@
import 'dart:developer' show log;
import 'package:flutter_bloc/flutter_bloc.dart';
import 'player_state.dart';
import '../api/events.dart';
class PlayerStateBloc extends Bloc<Event, PlayerState?> {
PlayerStateBloc() : super(null) {
on<InitialPlayerState>((event, emit) {
emit(event.playerState);
});
on <ClearPlayerState>((event, emit) {
emit(null);
});
on<PropertyChangedEvent>((event, emit) {
// print('Received event: $event');
if (state == null) {
log('Received event before initial state');
return;
}
switch (event) {
case PlaylistChange playlistChange:
final newState = state!.copyWith(playlist: playlistChange.playlist);
emit(newState);
break;
case LoopPlaylistChange loopPlaylistChange:
final newState =
state!.copyWith(isLooping: loopPlaylistChange.isLooping);
emit(newState);
break;
case PercentPositionChange percentPositionChange:
final newState = state!.copyWith(
currentPercentPosition:
percentPositionChange.currentPercentPosition,
);
emit(newState);
break;
case VolumeChange volumeChange:
final newState = state!.copyWith(volume: volumeChange.volume);
emit(newState);
break;
case DurationChange durationChange:
final newState = state!.copyWith(duration: durationChange.duration);
emit(newState);
break;
case PauseChange pauseChange:
final newState = state!.copyWith(isPlaying: !pauseChange.isPaused);
emit(newState);
break;
case MuteChange muteChange:
final newState = state!.copyWith(isMuted: muteChange.isMuted);
emit(newState);
break;
case TrackListChange trackListChange:
final newState =
state!.copyWith(subtitleTracks: trackListChange.tracks);
emit(newState);
break;
case DemuxerCacheStateChange demuxerCacheStateChange:
final newState = state!.copyWith(
cachedTimestamp: demuxerCacheStateChange.cachedTimestamp,
);
emit(newState);
break;
case PausedForCacheChange pausedForCacheChange:
final newState = state!.copyWith(
isPausedForCache: pausedForCacheChange.isPausedForCache,
);
emit(newState);
break;
}
});
}
}