feat: Android TV support (#503)

Co-authored-by: PartyDonut <PartyDonut@users.noreply.github.com>
This commit is contained in:
PartyDonut 2025-09-28 21:07:49 +02:00 committed by GitHub
parent 7ab8c015b9
commit c299492d6d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
168 changed files with 12019 additions and 3073 deletions

View file

@ -14,17 +14,20 @@ import 'package:fladder/models/items/media_streams_model.dart';
import 'package:fladder/models/media_playback_model.dart';
import 'package:fladder/models/playback/playback_model.dart';
import 'package:fladder/models/settings/video_player_settings.dart';
import 'package:fladder/providers/arguments_provider.dart';
import 'package:fladder/providers/settings/client_settings_provider.dart';
import 'package:fladder/providers/settings/video_player_settings_provider.dart';
import 'package:fladder/providers/video_player_provider.dart';
import 'package:fladder/src/video_player_helper.g.dart' hide PlaybackState;
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/wrappers/players/base_player.dart';
import 'package:fladder/wrappers/players/lib_mdk.dart'
if (dart.library.html) 'package:fladder/stubs/web/lib_mdk_web.dart';
import 'package:fladder/wrappers/players/lib_mpv.dart';
import 'package:fladder/wrappers/players/native_player.dart';
import 'package:fladder/wrappers/players/player_states.dart';
class MediaControlsWrapper extends BaseAudioHandler {
class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerControlsCallback {
MediaControlsWrapper({required this.ref});
BasePlayer? _player;
@ -49,11 +52,12 @@ class MediaControlsWrapper extends BaseAudioHandler {
List<StreamSubscription> subscriptions = [];
SMTCWindows? smtc;
bool initMediaControls = false;
bool initializedWrapper = false;
Future<void> init() async {
if (!initMediaControls) {
initMediaControls = true;
if (!initializedWrapper) {
initializedWrapper = true;
VideoPlayerControlsCallback.setUp(this);
await AudioService.init(
builder: () => this,
config: const AudioServiceConfig(
@ -69,10 +73,13 @@ class MediaControlsWrapper extends BaseAudioHandler {
);
}
final player = switch (ref.read(videoPlayerSettingsProvider.select((value) => value.wantedPlayer))) {
PlayerOptions.libMDK => LibMDK(),
PlayerOptions.libMPV => LibMPV(),
};
final player = ref.read(argumentsStateProvider).leanBackMode
? NativePlayer()
: switch (ref.read(videoPlayerSettingsProvider.select((value) => value.wantedPlayer))) {
PlayerOptions.libMDK => LibMDK(),
PlayerOptions.libMPV => LibMPV(),
PlayerOptions.nativePlayer => NativePlayer(),
};
setup(player);
}
@ -93,7 +100,15 @@ class MediaControlsWrapper extends BaseAudioHandler {
_subscribePlayer();
}
Future<void> open(String url, bool play) async => _player?.open(url, play);
Future<void> loadVideo(PlaybackModel model, Duration startPosition, bool play) async {
if (_player is NativePlayer) {
final context = ref.read(localizationContextProvider);
await (_player as NativePlayer).sendPlaybackDataToNative(context, model, startPosition);
}
return _player?.loadVideo(model.media?.url ?? "", play);
}
Future<void> openPlayer(BuildContext context) async => _player?.open(context);
void _subscribePlayer() {
if (Platform.isWindows && !kIsWeb) {
@ -313,4 +328,46 @@ class MediaControlsWrapper extends BaseAudioHandler {
_player?.setSpeed(speed);
return super.setSpeed(speed);
}
//Native player calls
//
//
@override
void loadNextVideo() async {
final nextVideo = ref.read(playBackModel.select((value) => value?.nextVideo));
final buffering = ref.read(mediaPlaybackProvider.select((value) => value.buffering));
if (nextVideo != null && !buffering) ref.read(playbackModelHelper).loadNewVideo(nextVideo);
}
@override
void loadPreviousVideo() async {
final previousVideo = ref.read(playBackModel.select((value) => value?.previousVideo));
final buffering = ref.read(mediaPlaybackProvider.select((value) => value.buffering));
if (previousVideo != null && !buffering) ref.read(playbackModelHelper).loadNewVideo(previousVideo);
}
@override
void onStop() => stop();
@override
void swapAudioTrack(int value) async {
final playbackModel = ref.read(playBackModel);
final newModel = await playbackModel?.setAudio(
playbackModel.audioStreams?.firstWhere((element) => element.index == value), this);
ref.read(playBackModel.notifier).update((state) => newModel);
if (newModel != null) {
await ref.read(playbackModelHelper).shouldReload(newModel);
}
}
@override
void swapSubtitleTrack(int value) async {
final playbackModel = ref.read(playBackModel);
final newModel = await playbackModel?.setSubtitle(
playbackModel.subStreams?.firstWhere((element) => element.index == value), this);
ref.read(playBackModel.notifier).update((state) => newModel);
if (newModel != null) {
await ref.read(playbackModelHelper).shouldReload(newModel);
}
}
}

View file

@ -23,7 +23,8 @@ abstract class BasePlayer {
GlobalKey? controlsKey,
});
Future<void> dispose();
Future<void> open(String url, bool play);
Future<void> open(BuildContext context);
Future<void> loadVideo(String url, bool play);
Future<void> seek(Duration position);
Future<void> play();
Future<void> setVolume(double volume);

View file

@ -10,6 +10,7 @@ import 'package:video_player/video_player.dart';
import 'package:fladder/models/items/media_streams_model.dart';
import 'package:fladder/models/playback/playback_model.dart';
import 'package:fladder/models/settings/video_player_settings.dart';
import 'package:fladder/screens/video_player/video_player.dart' as video_screen;
import 'package:fladder/wrappers/players/base_player.dart';
import 'package:fladder/wrappers/players/player_states.dart';
@ -40,7 +41,7 @@ class LibMDK extends BasePlayer {
}
@override
Future<void> open(String url, bool play) async {
Future<void> loadVideo(String url, bool play) async {
if (_controller != null) {
_controller?.dispose();
}
@ -95,6 +96,13 @@ class LibMDK extends BasePlayer {
});
}
@override
Future<void> open(BuildContext context) async => Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute(
builder: (context) => const video_screen.VideoPlayer(),
),
);
@override
Future<void> pause() async => _controller?.pause();
@override

View file

@ -13,6 +13,7 @@ import 'package:fladder/models/playback/playback_model.dart';
import 'package:fladder/models/settings/subtitle_settings_model.dart';
import 'package:fladder/models/settings/video_player_settings.dart';
import 'package:fladder/providers/settings/subtitle_settings_provider.dart';
import 'package:fladder/screens/video_player/video_player.dart' as video_screen;
import 'package:fladder/util/subtitle_position_calculator.dart';
import 'package:fladder/wrappers/players/base_player.dart';
import 'package:fladder/wrappers/players/player_states.dart';
@ -82,11 +83,18 @@ class LibMPV extends BasePlayer {
}
@override
Future<void> open(String url, bool play) async {
Future<void> loadVideo(String url, bool play) async {
await _player?.open(mpv.Media(url), play: play);
return setState(lastState.update(buffering: true));
}
@override
Future<void> open(BuildContext context) async => Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute(
builder: (context) => const video_screen.VideoPlayer(),
),
);
List<mpv.SubtitleTrack> get subTracks => _player?.state.tracks.subtitle ?? [];
mpv.SubtitleTrack get subtitleTrack => _player?.state.track.subtitle ?? mpv.SubtitleTrack.no();

View file

@ -0,0 +1,167 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:fladder/models/items/media_streams_model.dart';
import 'package:fladder/models/playback/playback_model.dart';
import 'package:fladder/models/settings/video_player_settings.dart';
import 'package:fladder/src/video_player_helper.g.dart';
import 'package:fladder/wrappers/players/base_player.dart';
import 'package:fladder/wrappers/players/player_states.dart';
class NativePlayer extends BasePlayer implements VideoPlayerListenerCallback {
final player = VideoPlayerApi();
@override
Future<void> dispose() async {
return NativeVideoActivity().disposeActivity();
}
@override
Future<void> init(VideoPlayerSettingsModel settings) async => VideoPlayerListenerCallback.setUp(this);
@override
Future<void> loop(bool loop) {
return player.setLooping(loop);
}
@override
Future<void> loadVideo(String url, bool play) async => player.open(url, play);
@override
Future<void> open(BuildContext newContext) async => NativeVideoActivity().launchActivity();
@override
Future<void> pause() {
return player.pause();
}
@override
Future<void> play() => player.play();
@override
Future<void> playOrPause() async {
return;
}
@override
Future<void> seek(Duration position) {
return player.seekTo(position.inMilliseconds);
}
@override
Future<int> setAudioTrack(AudioStreamModel? model, PlaybackModel playbackModel) async {
return 0;
}
@override
Future<void> setSpeed(double speed) async {}
@override
Future<int> setSubtitleTrack(SubStreamModel? model, PlaybackModel playbackModel) async {
return 0;
}
@override
Future<void> setVolume(double volume) async {
return player.setVolume(volume);
}
@override
Future<void> stop() async {
return player.stop();
}
@override
Widget? subtitles(bool showOverlay, {GlobalKey<State<StatefulWidget>>? controlsKey}) => null;
@override
Widget? videoWidget(Key key, BoxFit fit) => null;
@override
void onPlaybackStateChanged(PlaybackState state) {
lastState = lastState.update(
playing: state.playing,
position: Duration(milliseconds: state.position),
buffer: Duration(milliseconds: state.buffered),
buffering: state.buffering,
);
_stateController.add(lastState);
}
final StreamController<PlayerState> _stateController = StreamController.broadcast();
@override
Stream<PlayerState> get stateStream => _stateController.stream;
Future<void> sendPlaybackDataToNative(
BuildContext? context,
PlaybackModel model,
Duration startPosition,
) async {
final playableData = PlayableData(
id: model.item.id,
title: model.item.title,
subTitle: context != null ? model.item.label(context) : "",
logoUrl: model.item.getPosters?.logo?.path,
startPosition: startPosition.inMilliseconds,
description: model.item.overview.summary,
defaultAudioTrack: model.mediaStreams?.defaultAudioStreamIndex ?? 1,
nextVideo: model.nextVideo?.name,
previousVideo: model.previousVideo?.name,
audioTracks: model.audioStreams
?.map(
(audio) => AudioTrack(
name: audio.displayTitle,
languageCode: audio.language,
codec: audio.codec,
index: audio.index,
external: false,
),
)
.toList() ??
[],
defaultSubtrack: model.mediaStreams?.defaultSubStreamIndex ?? 1,
subtitleTracks: model.subStreams
?.map(
(sub) => SubtitleTrack(
name: sub.displayTitle,
languageCode: sub.language,
codec: sub.codec,
index: sub.index,
external: sub.isExternal,
url: sub.url,
),
)
.toList() ??
[],
segments: model.mediaSegments?.segments
.map(
(e) => MediaSegment(
type: MediaSegmentType.values.firstWhere((element) => element.name == e.type.name),
name: context != null ? e.type.label(context) : e.type.name,
start: e.start.inMilliseconds,
end: e.end.inMilliseconds,
),
)
.toList() ??
[],
trickPlayModel: model.trickPlay != null
? TrickPlayModel(
width: model.trickPlay!.width,
height: model.trickPlay!.height,
tileWidth: model.trickPlay!.tileWidth,
tileHeight: model.trickPlay!.tileHeight,
thumbnailCount: model.trickPlay!.thumbnailCount,
interval: model.trickPlay!.interval.inMilliseconds,
images: model.trickPlay?.images ?? [])
: null,
chapters: model.chapters
?.map((e) => Chapter(name: e.name, url: e.imageUrl, time: e.startPosition.inMilliseconds))
.toList() ??
[],
url: model.media?.url ?? "",
);
player.sendPlayableModel(playableData);
}
}