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

@ -37,10 +37,14 @@ class JellyRequest implements Interceptor {
FutureOr<Response<BodyType>> intercept<BodyType>(Chain<BodyType> chain) async {
final connectivityNotifier = ref.read(connectivityStatusProvider.notifier);
try {
final serverUrl = Uri.parse(ref.read(userProvider)?.server ?? ref.read(authProvider).tempCredentials.server);
final serverUrl = Uri.parse(
ref.read(userProvider)?.server ?? ref.read(authProvider).serverLoginModel?.tempCredentials.server ?? "");
//Use current logged in user otherwise use the authprovider
var loginModel = ref.read(userProvider)?.credentials ?? ref.read(authProvider).tempCredentials;
var loginModel = ref.read(userProvider)?.credentials ?? ref.read(authProvider).serverLoginModel?.tempCredentials;
if (loginModel == null) throw UnimplementedError();
var headers = loginModel.header(ref);
final Response<BodyType> response = await chain.proceed(
applyHeaders(

View file

@ -1,6 +1,9 @@
import 'package:flutter/material.dart';
import 'package:chopper/chopper.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
import 'package:fladder/models/account_model.dart';
import 'package:fladder/models/credentials_model.dart';
import 'package:fladder/models/login_screen_model.dart';
@ -13,44 +16,133 @@ import 'package:fladder/providers/service_provider.dart';
import 'package:fladder/providers/shared_provider.dart';
import 'package:fladder/providers/user_provider.dart';
import 'package:fladder/providers/views_provider.dart';
import 'package:fladder/screens/login/lock_screen.dart';
import 'package:fladder/screens/shared/fladder_snackbar.dart';
import 'package:fladder/util/fladder_config.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/util/string_extensions.dart';
final authProvider = StateNotifierProvider<AuthNotifier, LoginScreenModel>((ref) {
return AuthNotifier(ref);
});
class AuthNotifier extends StateNotifier<LoginScreenModel> {
AuthNotifier(this.ref)
: super(
LoginScreenModel(
accounts: [],
tempCredentials: CredentialsModel.createNewCredentials(),
loading: false,
),
);
AuthNotifier(this.ref) : super(LoginScreenModel());
final Ref ref;
late final JellyService api = ref.read(jellyApiProvider);
Future<Response<List<AccountModel>>?> getPublicUsers() async {
try {
var response = await api.usersPublicGet(state.tempCredentials);
if (response.isSuccessful && response.body != null) {
var models = response.body ?? [];
BuildContext? context;
return response.copyWith(body: models.toList());
Future<void> initModel(BuildContext newContext) async {
context ??= newContext;
ref.read(userProvider.notifier).clear();
final currentAccounts = ref.read(authProvider.notifier).getSavedAccounts();
ref.read(lockScreenActiveProvider.notifier).update((state) => true);
if (FladderConfig.baseUrl != null) {
final url = FladderConfig.baseUrl;
state = state.copyWith(
hasBaseUrl: true,
);
if (url != null) {
await setServer(url);
}
return response.copyWith(body: []);
}
state = state.copyWith(
accounts: currentAccounts,
screen: currentAccounts.isEmpty ? LoginScreenType.login : LoginScreenType.users,
);
}
Future<void> _fetchServerInfo(String url) async {
try {
final newCredentials = CredentialsModel.createNewCredentials().copyWith(server: url.rtrim('/'));
final newLoginModel = ServerLoginModel(tempCredentials: newCredentials);
state = state.copyWith(
serverLoginModel: newLoginModel,
loading: true,
);
final publicUsers = (await getPublicUsers())?.body ?? [];
final quickConnectStatus = (await api.quickConnectEnabled()).body ?? false;
final branding = await api.getBranding();
final serverResponse = await api.systemInfoPublicGet();
state = state.copyWith(
screen: quickConnectStatus ? LoginScreenType.code : LoginScreenType.login,
serverLoginModel: newLoginModel.copyWith(
tempCredentials: newCredentials.copyWith(
serverName: serverResponse.body?.serverName,
serverId: serverResponse.body?.id,
),
accounts: publicUsers,
hasQuickConnect: quickConnectStatus,
serverMessage: branding.body?.loginDisclaimer,
),
loading: false,
);
} catch (e) {
return null;
state = state.copyWith(
errorMessage: context?.localized.invalidUrl,
loading: false,
);
if (context != null) {
fladderSnackbar(context!, title: context!.localized.unableToConnectHost);
}
}
}
String? _parseUrl(String url) {
if (url.isEmpty) {
return null;
}
if (!Uri.parse(url).isAbsolute) {
return context?.localized.invalidUrl;
}
if (!url.startsWith('https://') && !url.startsWith('http://')) {
return context?.localized.invalidUrlDesc;
}
return null;
}
Future<Response<List<AccountModel>>?> getPublicUsers() async {
try {
state = state.copyWith(loading: true);
final credentials = state.serverLoginModel?.tempCredentials;
if (credentials == null) return null;
var response = await api.usersPublicGet(credentials);
if (response.isSuccessful && response.body != null) {
var models = response.body ?? [];
return response.copyWith(body: models.toList());
}
state = state.copyWith(
serverLoginModel: state.serverLoginModel?.copyWith(
accounts: response.body ?? [],
),
);
return response.copyWith(body: []);
} catch (e) {
return null;
} finally {
state = state.copyWith(loading: false);
}
}
Future<Response<AccountModel>?> authenticateUsingSecret(String secret) async {
clearAllProviders();
var response = await api.quickConnectAuthenticate(secret);
return _createAccountModel(response);
}
Future<Response<AccountModel>?> authenticateByName(String userName, String password) async {
state = state.copyWith(loading: true);
clearAllProviders();
var response = await api.usersAuthenticateByNamePost(userName: userName, password: password);
CredentialsModel credentials = state.tempCredentials;
return _createAccountModel(response);
}
Future<Response<AccountModel>?> _createAccountModel(Response<AuthenticationResult> response) async {
CredentialsModel? credentials = state.serverLoginModel?.tempCredentials;
if (credentials == null) return null;
if (response.isSuccessful && (response.body?.accessToken?.isNotEmpty ?? false)) {
var serverResponse = await api.systemInfoPublicGet();
credentials = credentials.copyWith(
@ -68,16 +160,21 @@ class AuthNotifier extends StateNotifier<LoginScreenModel> {
);
ref.read(sharedUtilityProvider).addAccount(newUser);
ref.read(userProvider.notifier).userState = newUser;
state = state.copyWith(loading: false);
final currentAccounts = ref.read(authProvider.notifier).getSavedAccounts();
state = state.copyWith(
serverLoginModel: null,
accounts: currentAccounts,
);
return Response(response.base, newUser);
}
state = state.copyWith(loading: false);
return Response(response.base, null);
}
Future<Response?> logOutUser() async {
final currentUser = ref.read(userProvider);
state = state.copyWith(tempCredentials: CredentialsModel.createNewCredentials());
state = state.copyWith(serverLoginModel: null);
await ref.read(sharedUtilityProvider).removeAccount(currentUser);
clearAllProviders();
return null;
@ -95,10 +192,17 @@ class AuthNotifier extends StateNotifier<LoginScreenModel> {
ref.read(libraryScreenProvider.notifier).clear();
}
void setServer(String server) {
Future<void> setServer(String server) async {
final url = (state.hasBaseUrl ? FladderConfig.baseUrl : server);
if (url == null || server.isEmpty) return;
final isUrlValid = _parseUrl(url);
state = state.copyWith(
tempCredentials: state.tempCredentials.copyWith(server: server),
errorMessage: isUrlValid,
serverLoginModel: null,
);
if (isUrlValid == null) {
await _fetchServerInfo(url);
}
}
List<AccountModel> getSavedAccounts() {
@ -113,4 +217,27 @@ class AuthNotifier extends StateNotifier<LoginScreenModel> {
accounts.insert(newIndex, original);
ref.read(sharedUtilityProvider).saveAccounts(accounts);
}
void addNewUser() {
state = state.copyWith(
screen: LoginScreenType.login,
);
}
void goUserSelect() {
state = state.copyWith(
serverLoginModel: state.hasBaseUrl ? state.serverLoginModel : null,
screen: LoginScreenType.users,
);
}
void tryParseUrl(String server) {
if (server.isNotEmpty && state.errorMessage != null) {
final url = server;
final isUrlValid = _parseUrl(url);
state = state.copyWith(
errorMessage: isUrlValid,
);
}
}
}

View file

@ -26,9 +26,16 @@ class DashboardNotifier extends StateNotifier<HomeModel> {
final viewTypes =
ref.read(viewsProvider.select((value) => value.dashboardViews)).map((e) => e.collectionType).toSet().toList();
final imagesToFetch = {
ImageType.logo,
ImageType.primary,
ImageType.backdrop,
ImageType.banner,
}.toList();
if (viewTypes.containsAny([CollectionType.movies, CollectionType.tvshows])) {
final resumeVideoResponse = await api.usersUserIdItemsResumeGet(
limit: 16,
enableImageTypes: imagesToFetch,
fields: [
ItemFields.parentid,
ItemFields.mediastreams,
@ -36,6 +43,8 @@ class DashboardNotifier extends StateNotifier<HomeModel> {
ItemFields.candelete,
ItemFields.candownload,
ItemFields.primaryimageaspectratio,
ItemFields.overview,
ItemFields.genres,
],
mediaTypes: [MediaType.video],
enableTotalRecordCount: false,
@ -48,7 +57,7 @@ class DashboardNotifier extends StateNotifier<HomeModel> {
if (viewTypes.contains(CollectionType.music)) {
final resumeAudioResponse = await api.usersUserIdItemsResumeGet(
limit: 16,
enableImageTypes: imagesToFetch,
fields: [
ItemFields.parentid,
ItemFields.mediastreams,
@ -56,6 +65,8 @@ class DashboardNotifier extends StateNotifier<HomeModel> {
ItemFields.candelete,
ItemFields.candownload,
ItemFields.primaryimageaspectratio,
ItemFields.overview,
ItemFields.genres,
],
mediaTypes: [MediaType.audio],
enableTotalRecordCount: false,
@ -68,7 +79,7 @@ class DashboardNotifier extends StateNotifier<HomeModel> {
if (viewTypes.contains(CollectionType.books)) {
final resumeBookResponse = await api.usersUserIdItemsResumeGet(
limit: 16,
enableImageTypes: imagesToFetch,
fields: [
ItemFields.parentid,
ItemFields.mediastreams,
@ -76,6 +87,8 @@ class DashboardNotifier extends StateNotifier<HomeModel> {
ItemFields.candelete,
ItemFields.candownload,
ItemFields.primaryimageaspectratio,
ItemFields.overview,
ItemFields.genres,
],
mediaTypes: [MediaType.book],
enableTotalRecordCount: false,
@ -87,7 +100,6 @@ class DashboardNotifier extends StateNotifier<HomeModel> {
}
final nextResponse = await api.showsNextUpGet(
limit: 16,
nextUpDateCutoff: DateTime.now().subtract(
ref.read(clientSettingsProvider.select((value) => value.nextUpDateCutoff ?? const Duration(days: 28)))),
fields: [
@ -97,6 +109,8 @@ class DashboardNotifier extends StateNotifier<HomeModel> {
ItemFields.candelete,
ItemFields.candownload,
ItemFields.primaryimageaspectratio,
ItemFields.overview,
ItemFields.genres,
],
);

View file

@ -6,7 +6,7 @@ import 'package:fladder/providers/user_provider.dart';
const _defaultHeight = 576;
const _defaultWidth = 384;
const _defaultQuality = 96;
const _defaultQuality = 90;
final imageUtilityProvider = Provider<ImageNotifier>((ref) {
return ImageNotifier(ref: ref);
@ -19,7 +19,7 @@ class ImageNotifier {
});
String get currentServerUrl {
return ref.read(userProvider)?.server ?? ref.read(authProvider).tempCredentials.server;
return ref.read(userProvider)?.server ?? ref.read(authProvider).serverLoginModel?.tempCredentials.server ?? "";
}
String getUserImageUrl(String id) {

View file

View file

@ -77,7 +77,7 @@ class JellyService {
final JellyfinOpenApi _api;
JellyfinOpenApi get api {
var authServer = ref.read(authProvider).tempCredentials.server;
var authServer = ref.read(authProvider).serverLoginModel?.tempCredentials.server ?? "";
var currentServer = ref.read(userProvider)?.credentials.server;
if ((authServer.isNotEmpty ? authServer : currentServer) == FakeHelper.fakeTestServerUrl) {
return FakeJellyfinOpenApi();
@ -1126,6 +1126,8 @@ class JellyService {
Future<Response<bool>> quickConnectEnabled() async => api.quickConnectEnabledGet();
Future<Response<BrandingOptions>> getBranding() async => api.brandingConfigurationGet();
Future<Response<dynamic>> deleteItem(String itemId) => api.itemsItemIdDelete(itemId: itemId);
Future<UserConfiguration?> _updateUserConfiguration(UserConfiguration newUserConfiguration) async {
@ -1161,6 +1163,22 @@ class JellyService {
);
return _updateUserConfiguration(updated);
}
Future<Response<QuickConnectResult>> quickConnectInitiate() async {
return api.quickConnectInitiatePost();
}
Future<Response<QuickConnectResult>> quickConnectConnectGet({
String? secret,
}) async {
return api.quickConnectConnectGet(secret: secret);
}
Future<Response<AuthenticationResult>> quickConnectAuthenticate(String secret) async {
return api.usersAuthenticateWithQuickConnectPost(
body: QuickConnectDto(secret: secret),
);
}
}
extension ParsedMap on Map<String, dynamic> {

View file

@ -5,10 +5,13 @@ import 'package:collection/collection.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:screen_brightness/screen_brightness.dart';
import 'package:fladder/models/items/media_segments_model.dart';
import 'package:fladder/models/settings/key_combinations.dart';
import 'package:fladder/models/settings/video_player_settings.dart';
import 'package:fladder/providers/shared_provider.dart';
import 'package:fladder/providers/user_provider.dart';
import 'package:fladder/providers/video_player_provider.dart';
import 'package:fladder/src/player_settings_helper.g.dart' as pigeon;
final videoPlayerSettingsProvider =
StateNotifierProvider<VideoPlayerSettingsProviderNotifier, VideoPlayerSettingsModel>((ref) {
@ -30,6 +33,30 @@ class VideoPlayerSettingsProviderNotifier extends StateNotifier<VideoPlayerSetti
if (!oldState.playerSame(value)) {
ref.read(videoPlayerProvider.notifier).init();
}
final userData = ref.read(userProvider);
pigeon.PlayerSettingsPigeon().sendPlayerSettings(
pigeon.PlayerSettings(
skipTypes: value.segmentSkipSettings.map(
(key, value) => MapEntry(
switch (key) {
MediaSegmentType.unknown => pigeon.SegmentType.intro,
MediaSegmentType.commercial => pigeon.SegmentType.commercial,
MediaSegmentType.preview => pigeon.SegmentType.preview,
MediaSegmentType.recap => pigeon.SegmentType.recap,
MediaSegmentType.outro => pigeon.SegmentType.outro,
MediaSegmentType.intro => pigeon.SegmentType.intro,
},
switch (value) {
SegmentSkip.none => pigeon.SegmentSkip.none,
SegmentSkip.askToSkip => pigeon.SegmentSkip.ask,
SegmentSkip.skip => pigeon.SegmentSkip.skip,
},
),
),
skipBackward: (userData?.userSettings?.skipBackDuration ?? const Duration(seconds: 15)).inMilliseconds,
skipForward: (userData?.userSettings?.skipForwardDuration ?? const Duration(seconds: 30)).inMilliseconds,
),
);
}
void setScreenBrightness(double? value) async {

View file

@ -55,10 +55,30 @@ class SharedUtility {
}
Future<bool?> addAccount(AccountModel account) async {
return await saveAccounts(getAccounts()
..add(account.copyWith(
lastUsed: DateTime.now(),
)));
final newAccount = account.copyWith(
lastUsed: DateTime.now(),
);
List<AccountModel> accounts = getAccounts().toList();
if (accounts.any((element) => element.sameIdentity(newAccount))) {
accounts = accounts
.map(
(e) => e.sameIdentity(newAccount)
? e.copyWith(
credentials: newAccount.credentials,
lastUsed: newAccount.lastUsed,
)
: e,
)
.toList();
} else {
accounts = [
...accounts,
newAccount,
];
}
return await saveAccounts(accounts);
}
Future<bool?> removeAccount(AccountModel? account) async {

View file

@ -1,5 +1,7 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/media_playback_model.dart';
@ -77,6 +79,8 @@ class VideoPlayerNotifier extends StateNotifier<MediaControlsWrapper> {
mediaState.update(
(state) => state.playing == event ? state : state.copyWith(playing: event),
);
final currentState = playbackState;
ref.read(playBackModel)?.updatePlaybackPosition(currentState.position, playbackState.playing, ref);
}
Future<void> updatePosition(Duration event) async {
@ -105,7 +109,7 @@ class VideoPlayerNotifier extends StateNotifier<MediaControlsWrapper> {
}
}
Future<bool> loadPlaybackItem(PlaybackModel model, {Duration? startPosition}) async {
Future<bool> loadPlaybackItem(PlaybackModel model, Duration startPosition) async {
await state.stop();
mediaState
.update((state) => state.copyWith(state: VideoPlayerState.fullScreen, buffering: true, errorPlaying: false));
@ -114,13 +118,13 @@ class VideoPlayerNotifier extends StateNotifier<MediaControlsWrapper> {
PlaybackModel? newPlaybackModel = model;
if (media != null) {
await state.open(media.url, false);
await state.loadVideo(model, startPosition, false);
await state.setVolume(ref.read(videoPlayerSettingsProvider).volume);
state.stateStream?.takeWhile((event) => event.buffering == true).listen(
null,
onDone: () async {
final start = startPosition ?? await model.startDuration();
if (start != null) {
final start = startPosition;
if (start != Duration.zero) {
await state.seek(start);
}
await state.setAudioTrack(null, model);
@ -138,4 +142,6 @@ class VideoPlayerNotifier extends StateNotifier<MediaControlsWrapper> {
mediaState.update((state) => state.copyWith(errorPlaying: true));
return false;
}
Future<void> openPlayer(BuildContext context) async => state.openPlayer(context);
}

View file

@ -65,6 +65,7 @@ class ViewsNotifier extends StateNotifier<ViewsModel> {
ItemFields.candelete,
ItemFields.candownload,
ItemFields.primaryimageaspectratio,
ItemFields.overview,
],
);
return e.copyWith(recentlyAdded: recents.body?.map((e) => ItemBaseModel.fromBaseDto(e, ref)).toList());