diff --git a/Fladder-Tizen.sln b/Fladder-Tizen.sln new file mode 100644 index 000000000..fcf439898 --- /dev/null +++ b/Fladder-Tizen.sln @@ -0,0 +1,29 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tizen", "tizen", "{A388AB0E-F47B-31FD-1F3C-3AE867F75632}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Runner", "tizen\Runner.csproj", "{11CBC428-FFDB-ECCF-FBBF-1DB39BFFB147}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {11CBC428-FFDB-ECCF-FBBF-1DB39BFFB147}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {11CBC428-FFDB-ECCF-FBBF-1DB39BFFB147}.Debug|Any CPU.Build.0 = Debug|Any CPU + {11CBC428-FFDB-ECCF-FBBF-1DB39BFFB147}.Release|Any CPU.ActiveCfg = Release|Any CPU + {11CBC428-FFDB-ECCF-FBBF-1DB39BFFB147}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {11CBC428-FFDB-ECCF-FBBF-1DB39BFFB147} = {A388AB0E-F47B-31FD-1F3C-3AE867F75632} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {38085DC2-CEE5-4C57-8CBF-1D53D4E7A0F4} + EndGlobalSection +EndGlobal diff --git a/lib/main.dart b/lib/main.dart index 38f698ab9..60afe0fab 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:io'; import 'dart:ui'; +import 'dart:ffi' as ffi; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -46,8 +47,11 @@ import 'package:fladder/util/themes_data.dart'; import 'package:fladder/util/window_helper.dart'; import 'package:fladder/widgets/media_query_scaler.dart'; +import 'package:flutter_tizen/flutter_tizen.dart'; +import 'package:sqlite3/open.dart' as sqlite3_open; + bool get _isDesktop { - if (kIsWeb) return false; + if (kIsWeb || isTizen) return false; return [ TargetPlatform.windows, TargetPlatform.linux, @@ -63,6 +67,12 @@ Future> loadConfig() async { } void main(List args) async { + if (isTizen) { + sqlite3_open.open.overrideFor(sqlite3_open.OperatingSystem.linux, () { + return ffi.DynamicLibrary.open('/usr/share/dotnet.tizen/lib/libsqlite3.so'); + }); + } + WidgetsFlutterBinding.ensureInitialized(); final crashProvider = CrashLogNotifier(); @@ -297,12 +307,32 @@ class _MainState extends ConsumerState
with WindowListener, WidgetsBinding final scrollBehaviour = const MaterialScrollBehavior(); return DynamicColorBuilder( builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) { - final lightTheme = themeColor == null + final rawLightTheme = themeColor == null ? FladderTheme.theme(lightDynamic ?? FladderTheme.defaultScheme(Brightness.light), schemeVariant) : FladderTheme.theme(themeColor.schemeLight, schemeVariant); - final darkTheme = (themeColor == null + final rawDarkTheme = (themeColor == null ? FladderTheme.theme(darkDynamic ?? FladderTheme.defaultScheme(Brightness.dark), schemeVariant) : FladderTheme.theme(themeColor.schemeDark, schemeVariant)); + final lightTheme = !isTizen + ? rawLightTheme + : rawLightTheme.copyWith( + pageTransitionsTheme: PageTransitionsTheme( + builders: { + ...rawLightTheme.pageTransitionsTheme.builders, + TargetPlatform.linux: const FadeUpwardsPageTransitionsBuilder(), + }, + ), + ); + final darkTheme = !isTizen + ? rawDarkTheme + : rawDarkTheme.copyWith( + pageTransitionsTheme: PageTransitionsTheme( + builders: { + ...rawDarkTheme.pageTransitionsTheme.builders, + TargetPlatform.linux: const FadeUpwardsPageTransitionsBuilder(), + }, + ), + ); final amoledOverwrite = amoledBlack ? Colors.black : null; return ThemesData( light: lightTheme, @@ -359,4 +389,4 @@ class _MainState extends ConsumerState
with WindowListener, WidgetsBinding } } -final currentTitleProvider = StateProvider((ref) => "Fladder"); +final currentTitleProvider = StateProvider((ref) => "Fladder"); \ No newline at end of file diff --git a/lib/models/playback/playback_model.dart b/lib/models/playback/playback_model.dart index bc614a540..862777e18 100644 --- a/lib/models/playback/playback_model.dart +++ b/lib/models/playback/playback_model.dart @@ -359,6 +359,7 @@ class PlaybackModelHelper { bitRateOptions: qualityOptions, ); } else if ((mediaSource.supportsTranscoding ?? false) && mediaSource.transcodingUrl != null) { + return TranscodePlaybackModel( item: item, queue: libraryQueue, @@ -500,6 +501,7 @@ class PlaybackModelHelper { bitRateOptions: playbackModel.bitRateOptions, ); } else if ((mediaSource.supportsTranscoding ?? false) && mediaSource.transcodingUrl != null) { + newModel = TranscodePlaybackModel( item: playbackModel.item, queue: playbackModel.queue, diff --git a/lib/models/settings/arguments_model.dart b/lib/models/settings/arguments_model.dart index 73708db43..bbff29f0b 100644 --- a/lib/models/settings/arguments_model.dart +++ b/lib/models/settings/arguments_model.dart @@ -1,4 +1,5 @@ import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:flutter_tizen/flutter_tizen.dart'; part 'arguments_model.freezed.dart'; @@ -17,11 +18,11 @@ abstract class ArgumentsModel with _$ArgumentsModel { factory ArgumentsModel.fromArguments(List arguments, String windowArguments, bool leanBackEnabled) { arguments = arguments.map((e) => e.trim()).toList(); - leanBackMode = leanBackEnabled; + leanBackMode = leanBackEnabled || isTizen; final parsedWindowArgs = windowArguments.split(','); return ArgumentsModel( - htpcMode: arguments.contains('--htpc') || leanBackEnabled, - leanBackMode: leanBackEnabled, + htpcMode: arguments.contains('--htpc') || leanBackEnabled || isTizen, + leanBackMode: leanBackEnabled || isTizen, newWindow: parsedWindowArgs.contains('--newWindow'), ); } diff --git a/lib/models/settings/video_player_settings.dart b/lib/models/settings/video_player_settings.dart index 636f73067..6300a53f5 100644 --- a/lib/models/settings/video_player_settings.dart +++ b/lib/models/settings/video_player_settings.dart @@ -11,6 +11,9 @@ import 'package:fladder/models/settings/key_combinations.dart'; import 'package:fladder/util/bitrate_helper.dart'; import 'package:fladder/util/localization_helper.dart'; + +import 'package:flutter_tizen/flutter_tizen.dart'; + part 'video_player_settings.freezed.dart'; part 'video_player_settings.g.dart'; @@ -90,7 +93,7 @@ abstract class VideoPlayerSettingsModel with _$VideoPlayerSettingsModel { factory VideoPlayerSettingsModel.fromJson(Map json) => _$VideoPlayerSettingsModelFromJson(json); PlayerOptions get wantedPlayer => - leanBackMode ? PlayerOptions.nativePlayer : playerOptions ?? PlayerOptions.platformDefaults; + leanBackMode && !isTizen ? PlayerOptions.nativePlayer : playerOptions ?? PlayerOptions.platformDefaults; Map get currentShortcuts => _defaultVideoHotKeys.map((key, value) => MapEntry(key, hotKeys[key] ?? value)); @@ -139,11 +142,12 @@ abstract class VideoPlayerSettingsModel with _$VideoPlayerSettingsModel { enum PlayerOptions { libMDK, libMPV, - nativePlayer; + nativePlayer, + tizenPlayer; const PlayerOptions(); - static Iterable get available => leanBackMode + static Iterable get available => leanBackMode && !isTizen ? {PlayerOptions.nativePlayer} : kIsWeb ? {PlayerOptions.libMPV} @@ -153,8 +157,10 @@ enum PlayerOptions { }; static PlayerOptions get platformDefaults { + if (isTizen) return PlayerOptions.tizenPlayer; if (leanBackMode) return PlayerOptions.nativePlayer; if (kIsWeb) return PlayerOptions.libMPV; + return switch (defaultTargetPlatform) { _ => PlayerOptions.libMPV, }; @@ -164,6 +170,7 @@ enum PlayerOptions { PlayerOptions.libMDK => "MDK", PlayerOptions.libMPV => "MPV", PlayerOptions.nativePlayer => "Native", + PlayerOptions.tizenPlayer => "Tizen", }; } diff --git a/lib/models/syncing/database_item.dart b/lib/models/syncing/database_item.dart index aa82add2d..1c76e7c21 100644 --- a/lib/models/syncing/database_item.dart +++ b/lib/models/syncing/database_item.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:io'; +import 'package:drift/native.dart'; import 'package:drift/drift.dart'; import 'package:drift_flutter/drift_flutter.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -17,6 +18,8 @@ import 'package:fladder/models/items/trick_play_model.dart'; import 'package:fladder/models/syncing/sync_item.dart'; import 'package:fladder/providers/user_provider.dart'; +import 'package:flutter_tizen/flutter_tizen.dart'; +import 'package:sqlite3/sqlite3.dart'; part 'database_item.g.dart'; const _databseName = 'syncedDatabase'; @@ -194,6 +197,14 @@ class AppDatabase extends _$AppDatabase { } static QueryExecutor _openConnection() { + if (isTizen) { + return _openConnectionTizen(); + } else { + return _openConnectionDefault(); + } + } + + static QueryExecutor _openConnectionDefault() { return driftDatabase( name: _databseName, native: const DriftNativeOptions( @@ -205,4 +216,15 @@ class AppDatabase extends _$AppDatabase { ), ); } + + static LazyDatabase _openConnectionTizen() { + return LazyDatabase(() async { + final dir = await getApplicationSupportDirectory(); + final file = File(p.join(dir.path, '$_databseName.sqlite')); + + return NativeDatabase.opened( + sqlite3.open(file.path), + ); + }); + } } diff --git a/lib/profiles/default_profile.dart b/lib/profiles/default_profile.dart index f33505d68..93926a82e 100644 --- a/lib/profiles/default_profile.dart +++ b/lib/profiles/default_profile.dart @@ -1,44 +1,54 @@ import 'package:flutter/foundation.dart'; +import 'package:flutter_tizen/flutter_tizen.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart'; import 'package:fladder/models/settings/video_player_settings.dart'; +import 'package:fladder/profiles/tizen_profile.dart'; import 'package:fladder/profiles/web_profile.dart'; import 'package:fladder/providers/video_player_provider.dart'; final videoProfileProvider = StateProvider.autoDispose((ref) => defaultProfile(ref.read(videoPlayerProvider.select((value) => value.backend)) ?? PlayerOptions.platformDefaults)); -DeviceProfile defaultProfile(PlayerOptions player) => kIsWeb - ? webProfile - : const DeviceProfile( - maxStreamingBitrate: 120000000, - maxStaticBitrate: 120000000, - musicStreamingTranscodingBitrate: 384000, - directPlayProfiles: [ - DirectPlayProfile( - type: DlnaProfileType.video, - ), - DirectPlayProfile( - type: DlnaProfileType.audio, - ) - ], - transcodingProfiles: [ - TranscodingProfile( - audioCodec: 'aac,mp3,mp2', - container: 'ts', - maxAudioChannels: '2', - protocol: MediaStreamProtocol.hls, - type: DlnaProfileType.video, - videoCodec: 'h264', - ), - ], - containerProfiles: [], - subtitleProfiles: [ - SubtitleProfile(format: 'vtt', method: SubtitleDeliveryMethod.$external), - SubtitleProfile(format: 'ass', method: SubtitleDeliveryMethod.$external), - SubtitleProfile(format: 'ssa', method: SubtitleDeliveryMethod.$external), - SubtitleProfile(format: 'pgssub', method: SubtitleDeliveryMethod.$external), - ], - ); +DeviceProfile defaultProfile(PlayerOptions player) { + if (kIsWeb) { + return webProfile; + } + + if (isTizen) { + return tizenProfile; + } + + return const DeviceProfile( + maxStreamingBitrate: 120000000, + maxStaticBitrate: 120000000, + musicStreamingTranscodingBitrate: 384000, + directPlayProfiles: [ + DirectPlayProfile( + type: DlnaProfileType.video, + ), + DirectPlayProfile( + type: DlnaProfileType.audio, + ) + ], + transcodingProfiles: [ + TranscodingProfile( + audioCodec: 'aac,mp3,mp2', + container: 'ts', + maxAudioChannels: '2', + protocol: MediaStreamProtocol.hls, + type: DlnaProfileType.video, + videoCodec: 'h264', + ), + ], + containerProfiles: [], + subtitleProfiles: [ + SubtitleProfile(format: 'vtt', method: SubtitleDeliveryMethod.$external), + SubtitleProfile(format: 'ass', method: SubtitleDeliveryMethod.$external), + SubtitleProfile(format: 'ssa', method: SubtitleDeliveryMethod.$external), + SubtitleProfile(format: 'pgssub', method: SubtitleDeliveryMethod.$external), + ], + ); +} diff --git a/lib/profiles/tizen_profile.dart b/lib/profiles/tizen_profile.dart new file mode 100644 index 000000000..635dae71a --- /dev/null +++ b/lib/profiles/tizen_profile.dart @@ -0,0 +1,199 @@ +import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart'; + +/// Tizen TV device profile +/// - TV supports H.264, HEVC, VP9, AV1 video +/// - TV supports AAC, MP3, FLAC, WAV, Vorbis, Opus, E-AC-3, AC-3 audio +/// - TV does NOT support DTS or most TrueHD +/// - Audio-only transcoding will handle unsupported audio +const DeviceProfile tizenProfile = DeviceProfile( + maxStreamingBitrate: 120000000, + maxStaticBitrate: 120000000, + musicStreamingTranscodingBitrate: 384000, + + // ------------------------ + // Direct Play Profiles + // ------------------------ + directPlayProfiles: [ + // Video: allow only codecs Tizen supports + DirectPlayProfile( + type: DlnaProfileType.video, + videoCodec: 'h264,hevc,vp8,vp9,av1', + audioCodec: 'aac,mp3,flac,wav,vorbis,opus,eac3,ac3', // exclude dts/truehd + ), + // Audio-only + DirectPlayProfile(container: 'mp3', type: DlnaProfileType.audio), + DirectPlayProfile(container: 'aac', type: DlnaProfileType.audio), + DirectPlayProfile(container: 'm4a', audioCodec: 'aac', type: DlnaProfileType.audio), + DirectPlayProfile(container: 'm4b', audioCodec: 'aac', type: DlnaProfileType.audio), + DirectPlayProfile(container: 'flac', type: DlnaProfileType.audio), + DirectPlayProfile(container: 'wav', type: DlnaProfileType.audio), + DirectPlayProfile(container: 'ogg', type: DlnaProfileType.audio), + ], + + // ------------------------ + // Transcoding Profiles + // ------------------------ + transcodingProfiles: [ + // Video copy + audio transcode for unsupported audio (e.g., DTS/TrueHD) + TranscodingProfile( + type: DlnaProfileType.video, + protocol: MediaStreamProtocol.hls, + container: 'ts', + videoCodec: 'copy', // copy video, no transcode + audioCodec: 'eac3,ac3,aac', // transcode unsupported audio + context: EncodingContext.streaming, + maxAudioChannels: '6', // support up to 5.1 + ), + // Audio-only transcoding for playback + TranscodingProfile( + type: DlnaProfileType.audio, + protocol: MediaStreamProtocol.http, + container: 'mp4', + audioCodec: 'aac,eac3,ac3', + context: EncodingContext.streaming, + maxAudioChannels: '6', + ), + ], + + containerProfiles: [], + + // ------------------------ + // Codec Profiles + // ------------------------ + codecProfiles: [ + // Video codec profiles: allow direct play if Tizen supports it + CodecProfile( + type: CodecType.video, + codec: 'h264', + conditions: [ + ProfileCondition( + condition: ProfileConditionType.notequals, + property: ProfileConditionValue.isanamorphic, + $Value: 'true', + ), + ProfileCondition( + condition: ProfileConditionType.equalsany, + property: ProfileConditionValue.videorangetype, + $Value: 'SDR', + ), + ProfileCondition( + condition: ProfileConditionType.lessthanequal, + property: ProfileConditionValue.videolevel, + $Value: '52', + ), + ProfileCondition( + condition: ProfileConditionType.notequals, + property: ProfileConditionValue.isinterlaced, + $Value: 'true', + ), + ], + ), + CodecProfile( + type: CodecType.video, + codec: 'hevc', + conditions: [ + ProfileCondition( + condition: ProfileConditionType.notequals, + property: ProfileConditionValue.isanamorphic, + $Value: 'true', + ), + ProfileCondition( + condition: ProfileConditionType.equalsany, + property: ProfileConditionValue.videoprofile, + $Value: 'main', + ), + ProfileCondition( + condition: ProfileConditionType.equalsany, + property: ProfileConditionValue.videorangetype, + $Value: 'SDR|HDR10|HLG', + ), + ProfileCondition( + condition: ProfileConditionType.lessthanequal, + property: ProfileConditionValue.videolevel, + $Value: '120', + ), + ProfileCondition( + condition: ProfileConditionType.notequals, + property: ProfileConditionValue.isinterlaced, + $Value: 'true', + ), + ], + ), + CodecProfile( + type: CodecType.video, + codec: 'vp9', + conditions: [ + ProfileCondition( + condition: ProfileConditionType.equalsany, + property: ProfileConditionValue.videorangetype, + $Value: 'SDR|HDR10|HLG', + ), + ], + ), + CodecProfile( + type: CodecType.video, + codec: 'av1', + conditions: [ + ProfileCondition( + condition: ProfileConditionType.notequals, + property: ProfileConditionValue.isanamorphic, + $Value: 'true', + ), + ProfileCondition( + condition: ProfileConditionType.equalsany, + property: ProfileConditionValue.videoprofile, + $Value: 'main', + ), + ProfileCondition( + condition: ProfileConditionType.equalsany, + property: ProfileConditionValue.videorangetype, + $Value: 'SDR|HDR10|HLG', + ), + ProfileCondition( + condition: ProfileConditionType.lessthanequal, + property: ProfileConditionValue.videolevel, + $Value: '19', + ), + ], + ), + + // Audio codec profiles: allow direct play for supported audio + CodecProfile( + type: CodecType.videoaudio, + codec: 'aac', + conditions: [ + ProfileCondition( + condition: ProfileConditionType.equals, + property: ProfileConditionValue.issecondaryaudio, + $Value: 'false', + ), + ], + ), + CodecProfile( + type: CodecType.videoaudio, + codec: 'ac3', + ), + CodecProfile( + type: CodecType.videoaudio, + codec: 'eac3', + ), + CodecProfile( + type: CodecType.videoaudio, + codec: 'flac', + ), + CodecProfile( + type: CodecType.videoaudio, + codec: 'mp3', + ), + ], + + // ------------------------ + // Subtitles + // ------------------------ + subtitleProfiles: [ + SubtitleProfile(format: 'vtt', method: SubtitleDeliveryMethod.$external), + SubtitleProfile(format: 'ass', method: SubtitleDeliveryMethod.$external), + SubtitleProfile(format: 'ssa', method: SubtitleDeliveryMethod.$external), + SubtitleProfile(format: 'pgssub', method: SubtitleDeliveryMethod.$external), + ], +); diff --git a/lib/providers/sync_provider.dart b/lib/providers/sync_provider.dart index 9f20cfaa9..0eb48f895 100644 --- a/lib/providers/sync_provider.dart +++ b/lib/providers/sync_provider.dart @@ -43,6 +43,8 @@ import 'package:fladder/util/duration_extensions.dart'; import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/util/string_extensions.dart'; +import 'package:flutter_tizen/flutter_tizen.dart'; + final syncProvider = StateNotifierProvider((ref) => throw UnimplementedError()); final downloadTasksProvider = StateProvider.family((ref, id) => DownloadStream.empty()); @@ -169,7 +171,7 @@ class SyncNotifier extends StateNotifier { late final JellyService api = ref.read(jellyApiProvider); - String? get _savePath => !kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS) + String? get _savePath => !kIsWeb && !isTizen && (Platform.isWindows || Platform.isLinux || Platform.isMacOS) ? ref.read(clientSettingsProvider.select((value) => value.syncPath)) : mobileDirectory.path; diff --git a/lib/screens/settings/player_settings_page.dart b/lib/screens/settings/player_settings_page.dart index 1748d1201..398a47805 100644 --- a/lib/screens/settings/player_settings_page.dart +++ b/lib/screens/settings/player_settings_page.dart @@ -389,6 +389,10 @@ class _PlayerSettingsPageState extends ConsumerState { PlayerOptions.libMDK => SettingsMessageBox( messageType: MessageType.info, "${context.localized.noVideoPlayerOptions}\n${context.localized.mdkExperimental}"), + PlayerOptions.tizenPlayer => SettingsMessageBox( + messageType: MessageType.info, + "${context.localized.noVideoPlayerOptions}\n${"Tizen"}", + ), }, ), Column( diff --git a/lib/screens/video_player/tizen_video_player_controls.dart b/lib/screens/video_player/tizen_video_player_controls.dart new file mode 100644 index 000000000..7f0f98648 --- /dev/null +++ b/lib/screens/video_player/tizen_video_player_controls.dart @@ -0,0 +1,940 @@ +import 'dart:async'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:async/async.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:iconsax_plus/iconsax_plus.dart'; +import 'package:screen_brightness/screen_brightness.dart'; + +import 'package:fladder/models/item_base_model.dart'; +import 'package:fladder/models/items/media_segments_model.dart'; +import 'package:fladder/models/media_playback_model.dart'; +import 'package:fladder/models/playback/playback_model.dart'; +import 'package:fladder/providers/settings/client_settings_provider.dart'; +import 'package:fladder/providers/settings/video_player_settings_provider.dart'; +import 'package:fladder/providers/user_provider.dart'; +import 'package:fladder/providers/video_player_provider.dart'; +import 'package:fladder/screens/shared/default_title_bar.dart'; +import 'package:fladder/screens/shared/media/components/item_logo.dart'; +import 'package:fladder/screens/video_player/components/video_playback_information.dart'; +import 'package:fladder/screens/video_player/components/video_player_controls_extras.dart'; +import 'package:fladder/screens/video_player/components/video_player_options_sheet.dart'; +import 'package:fladder/screens/video_player/components/video_player_quality_controls.dart'; +import 'package:fladder/screens/video_player/components/video_player_seek_indicator.dart'; +import 'package:fladder/screens/video_player/components/video_progress_bar.dart'; +import 'package:fladder/screens/video_player/components/video_volume_slider.dart'; +import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; +import 'package:fladder/util/duration_extensions.dart'; +import 'package:fladder/util/input_handler.dart'; +import 'package:fladder/util/list_padding.dart'; +import 'package:fladder/util/localization_helper.dart'; +import 'package:fladder/util/string_extensions.dart'; +import 'package:fladder/widgets/full_screen_helpers/full_screen_wrapper.dart'; + +class TVControls extends ConsumerStatefulWidget { + const TVControls({super.key}); + + @override + ConsumerState createState() => _TVControlsState(); +} + +class _TVControlsState extends ConsumerState { + final GlobalKey _bottomControlsKey = GlobalKey(); + + late final initInputDevice = AdaptiveLayout.inputDeviceOf(context); + + late RestartableTimer timer = RestartableTimer( + const Duration(seconds: 5), + () => mounted ? toggleOverlay(value: false) : null, + ); + + double? previousVolume; + + final fadeDuration = const Duration(milliseconds: 350); + bool showOverlay = true; + bool wasPlaying = false; + + SystemUiMode? _currentSystemUiMode; + String? activeIndicator; + RestartableTimer? _activeIndicatorTimer; + final FocusNode controlsFocusNode = FocusNode(); + List navOrder = []; + int navIndex = 0; + final Map navFocusNodes = {}; + + late final double topPadding = MediaQuery.of(context).viewPadding.top; + late final double bottomPadding = MediaQuery.of(context).viewPadding.bottom; + + @override + void initState() { + super.initState(); + timer.reset(); + HardwareKeyboard.instance.addHandler(_tizenHardwareHandler); + } + + void _setActiveIndicator(String value) { + _activeIndicatorTimer?.cancel(); + if (!mounted) return; + setState(() { + activeIndicator = value; + }); + _activeIndicatorTimer = RestartableTimer(const Duration(seconds: 1), () { + if (!mounted) return; + setState(() { + activeIndicator = null; + }); + }); + } + + Widget _wrapNav(String id, Widget child) { + final node = navFocusNodes.putIfAbsent(id, () => FocusNode()); + return FocusableActionDetector( + focusNode: node, + enabled: showOverlay, + shortcuts: { + }, + actions: >{ + ActivateIntent: CallbackAction(onInvoke: (intent) { + activateSelection(); + return null; + }), + }, + onFocusChange: (hasFocus) { + if (hasFocus) { + final idx = navOrder.indexOf(id); + if (idx >= 0) { + setState(() { + navIndex = idx; + _setActiveIndicator(id); + }); + } + } + }, + child: Builder(builder: (context) { + final focused = Focus.of(context).hasFocus; + if (!showOverlay) return child; + return Container( + decoration: focused + ? BoxDecoration( + border: Border.all(color: Colors.white.withValues(alpha: 0.9), width: 2), + borderRadius: BorderRadius.circular(8), + ) + : null, + padding: focused ? const EdgeInsets.all(2) : EdgeInsets.zero, + child: child, + ); + }), + ); + } + + void ensureNavOrder() { + final List order = []; + order.add('options'); + order.add('subtitle'); + order.add('audio'); + order.add('previous'); + order.add('seekBack'); + order.add('playPause'); + order.add('seekForward'); + order.add('next'); + order.add('quality'); + + navOrder = order; + if (navIndex >= navOrder.length) navIndex = 0; + + final existing = Set.from(navFocusNodes.keys); + for (final id in navOrder) { + existing.remove(id); + navFocusNodes.putIfAbsent(id, () => FocusNode()); + } + + for (final id in existing) { + navFocusNodes[id]?.dispose(); + navFocusNodes.remove(id); + } + } + + void _moveSelection(int delta) { + if (navOrder.isEmpty) return; + setState(() { + navIndex = (navIndex + delta) % navOrder.length; + if (navIndex < 0) navIndex += navOrder.length; + _setActiveIndicator(navOrder[navIndex]); + }); + + final id = navOrder[navIndex]; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) navFocusNodes[id]?.requestFocus(); + }); + } + + void activateSelection() { + if (navOrder.isEmpty) return; + final id = navOrder[navIndex]; + switch (id) { + case 'options': + showVideoPlayerOptions(context, () => minimizePlayer(context)); + break; + case 'subtitle': + showSubSelection(context); + break; + case 'audio': + showAudioSelection(context); + break; + case 'previous': + loadPreviousVideo(ref)?.call(); + break; + case 'seekBack': + final backwardSpeed = + ref.read(userProvider.select((value) => value?.userSettings?.skipBackDuration.inSeconds ?? 10)); + seekBack(ref, seconds: backwardSpeed); + break; + case 'playPause': + doPlayPause(); + break; + case 'seekForward': + final forwardSpeed = + ref.read(userProvider.select((value) => value?.userSettings?.skipForwardDuration.inSeconds ?? 30)); + seekForward(ref, seconds: forwardSpeed); + break; + case 'next': + loadNextVideo(ref)?.call(); + break; + case 'quality': + openQualityOptions(context); + break; + default: + break; + } + } + + void doPlayPause() { + ref.read(videoPlayerProvider).playOrPause(); + _setActiveIndicator('Play/Pause'); + resetTimer(); + } + + @override + void dispose() { + HardwareKeyboard.instance.removeHandler(_tizenHardwareHandler); + _activeIndicatorTimer?.cancel(); + controlsFocusNode.dispose(); + for (final node in navFocusNodes.values) { + node.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // Build nav order for remote navigation each build so it reflects visible controls + ensureNavOrder(); + final mediaSegments = ref.watch(playBackModel.select((value) => value?.mediaSegments)); + final player = ref.watch(videoPlayerProvider); + final subtitleWidget = player.subtitleWidget(showOverlay, controlsKey: _bottomControlsKey); + + return Listener( + onPointerSignal: setVolume, + child: InputHandler( + autoFocus: true, + listenRawKeyboard: true, + child: PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) { + if (!didPop) { + closePlayer(); + } + }, + child: Stack( + children: [ + if (subtitleWidget != null) subtitleWidget, + Positioned.fill( + child: AnimatedOpacity( + duration: fadeDuration, + opacity: showOverlay ? 1 : 0, + child: Column( + children: [ + topButtons(context), + const Spacer(), + ExcludeFocus( + excluding: !showOverlay, + child: FocusTraversalGroup( + policy: OrderedTraversalPolicy(), + child: Focus( + focusNode: controlsFocusNode, + child: bottomButtons(context), + ), + ), + ), + ], + ), + ), + ), + if (!showOverlay) const VideoPlayerSeekIndicator(), + + Consumer( + builder: (context, ref, child) { + final position = ref.watch(mediaPlaybackProvider.select((value) => value.position)); + MediaSegment? segment = mediaSegments?.atPosition(position); + SegmentVisibility forceShow = + segment?.visibility(position, force: showOverlay) ?? SegmentVisibility.hidden; + final segmentSkipType = ref + .watch(videoPlayerSettingsProvider.select((value) => value.segmentSkipSettings[segment?.type])); + final autoSkip = forceShow != SegmentVisibility.hidden && + segmentSkipType == SegmentSkip.skip && + player.lastState?.buffering == false; + if (autoSkip) { + skipToSegmentEnd(segment); + } + return Stack( + children: [ + Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.all(32), + child: SkipSegmentButton( + segment: segment, + skipType: segmentSkipType, + visibility: forceShow, + pressedSkip: () => skipToSegmentEnd(segment), + ), + ), + ), + ], + ); + }, + ), + ], + ), + ), + ), + ); + } + + Widget playButton(bool playing, bool buffering) { + return Align( + alignment: Alignment.center, + child: AnimatedScale( + curve: Curves.easeInOutCubicEmphasized, + scale: playing + ? 0 + : buffering + ? 0 + : 1, + duration: const Duration(milliseconds: 250), + child: IconButton.outlined( + onPressed: () => ref.read(videoPlayerProvider).play(), + isSelected: true, + iconSize: 65, + tooltip: "Resume video", + icon: const Icon(IconsaxPlusBold.play), + ), + ), + ); + } + + Widget topButtons(BuildContext context) { + final currentItem = ref.watch(playBackModel.select((value) => value?.item)); + final maxHeight = 150.clamp(50, (MediaQuery.sizeOf(context).height * 0.25).clamp(51, double.maxFinite)).toDouble(); + return Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withValues(alpha: 0.8), + Colors.black.withValues(alpha: 0), + ], + )), + child: Padding( + padding: MediaQuery.paddingOf(context).copyWith(bottom: 0, top: 0), + child: Container( + alignment: Alignment.topCenter, + child: Column( + children: [ + const Align( + alignment: Alignment.topRight, + child: DefaultTitleBar(), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + spacing: 16, + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (currentItem != null) + Expanded( + child: Row( + children: [ + Flexible( + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: maxHeight, + ), + child: ItemLogo( + item: currentItem, + imageAlignment: Alignment.topLeft, + textStyle: Theme.of(context).textTheme.headlineLarge, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + + Widget bottomButtons(BuildContext context) { + return Consumer(builder: (context, ref, child) { + final mediaPlayback = ref.watch(mediaPlaybackProvider); + final bitRateOptions = ref.watch(playBackModel.select((value) => value?.bitRateOptions)); + return Container( + key: _bottomControlsKey, // Add key to measure height + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + Colors.black.withValues(alpha: 0.8), + Colors.black.withValues(alpha: 0), + ], + )), + child: Padding( + padding: MediaQuery.paddingOf(context).add( + const EdgeInsets.symmetric(horizontal: 16).copyWith(bottom: 12), + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: progressBar(mediaPlayback), + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + flex: 2, + child: Row( + children: [ + _wrapNav( + 'options', + IconButton( + onPressed: () => showVideoPlayerOptions(context, () => minimizePlayer(context)), + icon: const Icon(IconsaxPlusLinear.more)), + ), + if (AdaptiveLayout.layoutOf(context) == ViewSize.tablet) ...[ + _wrapNav( + 'subtitle', + IconButton( + onPressed: () => showSubSelection(context), + icon: const Icon(IconsaxPlusLinear.subtitle), + ), + ), + _wrapNav( + 'audio', + IconButton( + onPressed: () => showAudioSelection(context), + icon: const Icon(IconsaxPlusLinear.audio_square), + ), + ), + ], + if (AdaptiveLayout.layoutOf(context) == ViewSize.television) ...[ + Flexible( + child: _wrapNav( + 'subtitle', + ElevatedButton.icon( + onPressed: () => showSubSelection(context), + icon: const Icon(IconsaxPlusLinear.subtitle), + label: Text( + ref.watch(playBackModel.select((value) { + final language = value?.mediaStreams?.currentSubStream?.language; + return language?.isEmpty == true ? context.localized.off : language; + }))?.capitalize() ?? + "", + maxLines: 1, + ), + ), + ), + ), + Flexible( + child: _wrapNav( + 'audio', + ElevatedButton.icon( + onPressed: () => showAudioSelection(context), + icon: const Icon(IconsaxPlusLinear.audio_square), + label: Text( + ref.watch(playBackModel.select((value) { + final language = value?.mediaStreams?.currentAudioStream?.language; + return language?.isEmpty == true ? context.localized.off : language; + }))?.capitalize() ?? + "", + maxLines: 1, + ), + ), + ), + ) + ], + ].addInBetween(const SizedBox( + width: 4, + )), + ), + ), + previousButton, + seekBackwardButton(ref), + _wrapNav( + 'playPause', + // Wrap the IconButton in a Consumer so the Icon rebuilds + // specifically when the playing state changes. + Consumer(builder: (context, ref, child) { + final playing = ref.watch(mediaPlaybackProvider.select((m) => m.playing)); + return IconButton.filledTonal( + iconSize: 38, + onPressed: doPlayPause, + icon: Icon( + playing ? IconsaxPlusBold.pause : IconsaxPlusBold.play, + ), + ); + }), + ), + seekForwardButton(ref), + nextVideoButton, + Flexible( + flex: 2, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + // Removed the pointer close button to avoid showing a cross in the corner. + const Spacer(), + if (AdaptiveLayout.viewSizeOf(context) >= ViewSize.tablet && + ref.read(videoPlayerProvider).hasPlayer) ...{ + if (bitRateOptions?.isNotEmpty == true) + Tooltip( + message: context.localized.qualityOptionsTitle, + child: _wrapNav( + 'quality', + IconButton( + onPressed: () =>openQualityOptions(context), + icon: const Icon(IconsaxPlusLinear.speedometer), + ), + ), + ), + }, + if (initInputDevice == InputDevice.pointer && + AdaptiveLayout.viewSizeOf(context) > ViewSize.phone) ...[ + VideoVolumeSlider( + onChanged: () => resetTimer(), + ), + const FullScreenButton(), + ] + ].addInBetween(const SizedBox(width: 8)), + ), + ), + ].addInBetween(const SizedBox(width: 6)), + ), + ], + ), + ), + ); + }); + } + + Widget progressBar(MediaPlaybackModel mediaPlayback) { + return Consumer( + builder: (context, ref, child) { + final playbackModel = ref.watch(playBackModel); + final item = playbackModel?.item; + final List details = [ null + ]; + return Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Row( + children: [ + Expanded( + child: Text( + details.nonNulls.join(' - '), + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + maxLines: 2, + ), + ), + const Spacer(), + if (playbackModel != null) + InkWell( + onTap: () => showVideoPlaybackInformation(context), + child: Card( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Text( + playbackModel.label(context) ?? "", + ), + ), + ), + ), + if (item != null) ...{ + Card( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Text( + item.streamModel?.mediaInfoTag ?? "", + ), + ), + ), + }, + ].addPadding(const EdgeInsets.symmetric(horizontal: 4)), + ), + const SizedBox(height: 4), + SizedBox( + height: 25, + child: VideoProgressBar( + wasPlayingChanged: (value) => wasPlaying = value, + wasPlaying: wasPlaying, + duration: mediaPlayback.duration, + position: mediaPlayback.position, + buffer: mediaPlayback.buffer, + buffering: mediaPlayback.buffering, + timerReset: () => timer.reset(), + onPositionChanged: (position) => ref.read(videoPlayerProvider).seek(position), + ), + ), + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + mediaPlayback.position.readAbleDuration, + style: Theme.of(context).textTheme.bodyMedium, + ), + Text( + "-${(mediaPlayback.duration - mediaPlayback.position).readAbleDuration}", + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ], + ); + }, + ); + } + + Widget get previousButton { + return Consumer( + builder: (context, ref, child) { + final previousVideo = ref.watch(playBackModel.select((value) => value?.previousVideo)); + return Tooltip( + message: previousVideo?.detailedName(context) ?? "", + textAlign: TextAlign.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.95), + ), + textStyle: Theme.of(context).textTheme.labelLarge, + child: _wrapNav( + 'previous', + IconButton( + onPressed: loadPreviousVideo(ref, video: previousVideo), + iconSize: 30, + icon: const Icon( + IconsaxPlusLinear.backward, + ), + ), + ), + ); + }, + ); + } + + Function()? loadPreviousVideo(WidgetRef ref, {ItemBaseModel? video}) { + final previousVideo = video ?? ref.read(playBackModel.select((value) => value?.previousVideo)); + final buffering = ref.read(mediaPlaybackProvider.select((value) => value.buffering)); + return previousVideo != null && !buffering ? () => ref.read(playbackModelHelper).loadNewVideo(previousVideo) : null; + } + + Widget get nextVideoButton { + return Consumer( + builder: (context, ref, child) { + final nextVideo = ref.watch(playBackModel.select((value) => value?.nextVideo)); + return Tooltip( + message: nextVideo?.detailedName(context) ?? "", + textAlign: TextAlign.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.95), + ), + textStyle: Theme.of(context).textTheme.labelLarge, + child: _wrapNav( + 'next', + IconButton( + onPressed: loadNextVideo(ref, video: nextVideo), + iconSize: 30, + icon: const Icon( + IconsaxPlusLinear.forward, + ), + ), + ), + ); + }, + ); + } + + Function()? loadNextVideo(WidgetRef ref, {ItemBaseModel? video}) { + final nextVideo = video ?? ref.read(playBackModel.select((value) => value?.nextVideo)); + final buffering = ref.read(mediaPlaybackProvider.select((value) => value.buffering)); + return nextVideo != null && !buffering ? () => ref.read(playbackModelHelper).loadNewVideo(nextVideo) : null; + } + + Widget seekBackwardButton(WidgetRef ref) { + final backwardSpeed = 10; + //ref.read(userProvider.select((value) => value?.userSettings?.skipBackDuration.inSeconds ?? 30)); + return _wrapNav( + 'seekBack', + IconButton( + onPressed: () => seekBack(ref, seconds: backwardSpeed), + tooltip: "-$backwardSpeed", + iconSize: 40, + icon: Stack( + alignment: Alignment.center, + children: [ + const Icon( + IconsaxPlusBroken.refresh, + size: 45, + ), + Transform.translate( + offset: const Offset(0, 1), + child: Text( + "-$backwardSpeed", + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ), + ), + ); + } + + Widget seekForwardButton(WidgetRef ref) { + final forwardSpeed = + ref.read(userProvider.select((value) => value?.userSettings?.skipForwardDuration.inSeconds ?? 30)); + return _wrapNav( + 'seekForward', + IconButton( + onPressed: () => seekForward(ref, seconds: forwardSpeed), + tooltip: forwardSpeed.toString(), + iconSize: 40, + icon: Stack( + alignment: Alignment.center, + children: [ + Transform.flip( + flipX: true, + child: const Icon( + IconsaxPlusBroken.refresh, + size: 45, + ), + ), + Transform.translate( + offset: const Offset(0, 1), + child: Text( + forwardSpeed.toString(), + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ), + ), + ); + } + + void skipToSegmentEnd(MediaSegment? mediaSegments) { + final end = mediaSegments?.end; + if (end != null) { + resetTimer(); + ref.read(videoPlayerProvider).seek(end); + } + } + + void seekBack(WidgetRef ref, {int seconds = 15}) { + final mediaPlayback = ref.read(mediaPlaybackProvider); + resetTimer(); + final newPosition = (mediaPlayback.position.inSeconds - seconds).clamp(0, mediaPlayback.duration.inSeconds); + ref.read(videoPlayerProvider).seek(Duration(seconds: newPosition)); + } + + void seekForward(WidgetRef ref, {int seconds = 15}) { + final mediaPlayback = ref.read(mediaPlaybackProvider); + resetTimer(); + final newPosition = (mediaPlayback.position.inSeconds + seconds).clamp(0, mediaPlayback.duration.inSeconds); + ref.read(videoPlayerProvider).seek(Duration(seconds: newPosition)); + } + + void toggleOverlay({bool? value}) { + final desiredState = value ?? !showOverlay; + if (showOverlay == desiredState) return; + + String? indicatorToActivate; + setState(() { + showOverlay = desiredState; + if (showOverlay) { + final playPauseIndex = navOrder.indexOf('playPause'); + if (playPauseIndex != -1) { + navIndex = playPauseIndex; + indicatorToActivate = 'playPause'; + } + } + }); + if (indicatorToActivate != null) { + _setActiveIndicator(indicatorToActivate!); + } + resetTimer(); + + // When overlay becomes visible, set focus to the controls so arrow keys + // navigate the control buttons. + if (showOverlay) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) controlsFocusNode.requestFocus(); + }); + } + if (showOverlay) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && navOrder.isNotEmpty) { + final id = navOrder[navIndex]; + navFocusNodes[id]?.requestFocus(); + } + }); + } + + final desiredMode = showOverlay ? SystemUiMode.edgeToEdge : SystemUiMode.immersiveSticky; + + if (_currentSystemUiMode != desiredMode) { + _currentSystemUiMode = desiredMode; + SystemChrome.setEnabledSystemUIMode(desiredMode, overlays: []); + } + + SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + systemNavigationBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.light, + systemNavigationBarDividerColor: Colors.transparent, + )); + } + + void minimizePlayer(BuildContext context) { + clearOverlaySettings(); + ref.read(mediaPlaybackProvider.notifier).update((state) => state.copyWith(state: VideoPlayerState.minimized)); + Navigator.of(context).pop(); + } + + void resetTimer() => timer.reset(); + + Future closePlayer() async { + clearOverlaySettings(); + ref.read(videoPlayerProvider).stop(); + Navigator.of(context).pop(); + } + + Future clearOverlaySettings() async { + toggleOverlay(value: true); + if (initInputDevice != InputDevice.pointer) { + ScreenBrightness().resetApplicationScreenBrightness(); + } else { + disableFullScreen(); + } + + SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle( + statusBarIconBrightness: ref.read(clientSettingsProvider.select((value) => value.statusBarBrightness(context))), + )); + + timer.cancel(); + } + + Future disableFullScreen() async { + resetTimer(); + fullScreenHelper.closeFullScreen(ref); + } + + void setVolume(PointerEvent event) { + if (event is PointerScrollEvent) { + if (event.scrollDelta.dy > 0) { + ref.read(videoPlayerSettingsProvider.notifier).steppedVolume(-5); + } else { + ref.read(videoPlayerSettingsProvider.notifier).steppedVolume(5); + } + } + } + + bool _tizenHardwareHandler(KeyEvent event) { + // Only handle on key down to avoid double triggers + if (event is! KeyDownEvent) return false; + final key = event.logicalKey; + + // Common media keys + if (key == LogicalKeyboardKey.mediaPlayPause || key == LogicalKeyboardKey.mediaPlay || key == LogicalKeyboardKey.mediaPause) { + // toggle play/pause + ref.read(videoPlayerProvider).playOrPause(); + _setActiveIndicator('Play/Pause'); + resetTimer(); + return true; + } + + // If overlay is visible, use D-pad to navigate controls + if (showOverlay) { + if (key == LogicalKeyboardKey.arrowLeft) { + _moveSelection(-1); + resetTimer(); + return true; + } + if (key == LogicalKeyboardKey.arrowRight) { + _moveSelection(1); + resetTimer(); + return true; + } + // if (key == LogicalKeyboardKey.arrowUp) { + // // Move up a few steps (best-effort grid movement) + // _moveSelection(-3); + // return true; + // } + // if (key == LogicalKeyboardKey.arrowDown) { + // _moveSelection(3); + // return true; + // } + // Activation keys + + if (key == LogicalKeyboardKey.select || key == LogicalKeyboardKey.enter || key == LogicalKeyboardKey.space) { + //activateSelection(); + //_setActiveIndicator('Activate'); + resetTimer(); + return true; + } + } + + // // Arrow keys -> seek indicator (when overlay not visible) + // if (key == LogicalKeyboardKey.arrowLeft || key == LogicalKeyboardKey.arrowRight) { + // _setActiveIndicator('Seek'); + // // do not swallow here; other handlers (seek indicator) will process the key + // return false; + // } + + // Up/Down -> volume (when overlay not visible) + if (key == LogicalKeyboardKey.arrowUp || key == LogicalKeyboardKey.arrowDown || key == LogicalKeyboardKey.select || key == LogicalKeyboardKey.enter || key == LogicalKeyboardKey.space) { + // _setActiveIndicator('Volume'); + // do not swallow here; allow normal processing + toggleOverlay(value: true); + return false; + } + + return false; + } +} diff --git a/lib/screens/video_player/video_player.dart b/lib/screens/video_player/video_player.dart index c73f9586b..76ec15195 100644 --- a/lib/screens/video_player/video_player.dart +++ b/lib/screens/video_player/video_player.dart @@ -11,6 +11,8 @@ import 'package:fladder/providers/settings/video_player_settings_provider.dart'; import 'package:fladder/providers/video_player_provider.dart'; import 'package:fladder/screens/video_player/components/video_player_next_wrapper.dart'; import 'package:fladder/screens/video_player/video_player_controls.dart'; +import 'package:fladder/screens/video_player/tizen_video_player_controls.dart'; +import 'package:flutter_tizen/flutter_tizen.dart'; import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; import 'package:fladder/util/themes_data.dart'; import 'package:fladder/widgets/shared/back_intent_dpad.dart'; @@ -113,7 +115,7 @@ class _VideoPlayerState extends ConsumerState with WidgetsBindingOb : videoFit, ), ), - controls: const DesktopControls(), + controls: isTizen ? const TVControls() : const DesktopControls(), overlays: [ if (errorPlaying) const _VideoErrorWidget(), ], diff --git a/lib/util/adaptive_layout/adaptive_layout.dart b/lib/util/adaptive_layout/adaptive_layout.dart index 5ef52b35c..599bae5eb 100644 --- a/lib/util/adaptive_layout/adaptive_layout.dart +++ b/lib/util/adaptive_layout/adaptive_layout.dart @@ -15,6 +15,8 @@ import 'package:fladder/util/poster_defaults.dart'; import 'package:fladder/util/resolution_checker.dart'; import 'package:fladder/widgets/keyboard/slide_in_keyboard.dart'; +import 'package:flutter_tizen/flutter_tizen.dart'; + enum InputDevice { touch, pointer, @@ -155,7 +157,7 @@ class _AdaptiveLayoutBuilderState extends ConsumerState { } bool get isDesktop { - if (kIsWeb) return false; + if (kIsWeb || isTizen) return false; return [ TargetPlatform.macOS, TargetPlatform.windows, diff --git a/lib/util/resolution_checker.dart b/lib/util/resolution_checker.dart index a13c11080..dc2228c0e 100644 --- a/lib/util/resolution_checker.dart +++ b/lib/util/resolution_checker.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:device_info_plus_tizen/device_info_plus_tizen.dart'; +import 'package:flutter_tizen/flutter_tizen.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:screen_retriever/screen_retriever.dart'; @@ -20,12 +22,14 @@ class _ResolutionCheckerState extends ConsumerState { Size? lastResolution; Timer? _timer; + + @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((value) async { if (ref.read(argumentsStateProvider).htpcMode) { - lastResolution = (await screenRetriever.getPrimaryDisplay()).size; + lastResolution = isTizen ? await getTizenResolution() : (await screenRetriever.getPrimaryDisplay()).size; _timer = Timer.periodic(const Duration(seconds: 2), (timer) => checkResolution()); } }); @@ -33,7 +37,7 @@ class _ResolutionCheckerState extends ConsumerState { Future checkResolution() async { if (!mounted) return; - final newResolution = (await screenRetriever.getPrimaryDisplay()).size; + final newResolution = isTizen ? await getTizenResolution() : (await screenRetriever.getPrimaryDisplay()).size; if (lastResolution != newResolution) { lastResolution = newResolution; shouldSetResolution(); @@ -50,6 +54,13 @@ class _ResolutionCheckerState extends ConsumerState { } } + Future getTizenResolution() async { + final DeviceInfoPluginTizen deviceInfoPlugin = DeviceInfoPluginTizen(); + final tizenInfo = await deviceInfoPlugin.tizenInfo; + // this seem to return 1920x1080 even on 4K TVs :( + return Size(tizenInfo.screenWidth.toDouble(), tizenInfo.screenHeight.toDouble()); + } + @override void dispose() { _timer?.cancel(); diff --git a/lib/wrappers/media_control_wrapper.dart b/lib/wrappers/media_control_wrapper.dart index 0796fdfd7..f6c1e020b 100644 --- a/lib/wrappers/media_control_wrapper.dart +++ b/lib/wrappers/media_control_wrapper.dart @@ -25,10 +25,16 @@ import 'package:fladder/wrappers/players/lib_mdk.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'; +import 'package:fladder/wrappers/players/tizen_player.dart'; class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerControlsCallback { MediaControlsWrapper({required this.ref}); + /// Notifier used to inform UI that subtitle source/mode changed. + /// Incrementing the value will notify any listeners (e.g. subtitle widgets) + /// so they can refresh immediately without waiting for a full controls rebuild. + final ValueNotifier subtitleChangeNotifier = ValueNotifier(0); + BasePlayer? _player; bool get hasPlayer => _player != null; @@ -42,8 +48,18 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro Stream? get stateStream => _player?.stateStream; PlayerState? get lastState => _player?.lastState; - Widget? subtitleWidget(bool showOverlay, {GlobalKey? controlsKey}) => - _player?.subtitles(showOverlay, controlsKey: controlsKey); + Widget? subtitleWidget(bool showOverlay, {GlobalKey? controlsKey}) { + final inner = _player?.subtitles(showOverlay, controlsKey: controlsKey); + if (inner == null) return null; + + // Wrap the player's subtitle widget in a ValueListenableBuilder so we can + // force a rebuild when the subtitle source/mode changes without needing a + // full controls rebuild. + return ValueListenableBuilder( + valueListenable: subtitleChangeNotifier, + builder: (context, _, __) => _player?.subtitles(showOverlay, controlsKey: controlsKey) ?? const SizedBox.shrink(), + ); + } Widget? videoWidget(Key key, BoxFit fit) => _player?.videoWidget(key, fit); final Ref ref; @@ -78,6 +94,7 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro PlayerOptions.libMDK => LibMDK(), PlayerOptions.libMPV => LibMPV(), PlayerOptions.nativePlayer => NativePlayer(), + PlayerOptions.tizenPlayer => TizenPlayer(), }; setup(player); @@ -308,8 +325,14 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro Future setAudioTrack(AudioStreamModel? model, PlaybackModel playbackModel) async => await _player?.setAudioTrack(model, playbackModel) ?? -1; - Future setSubtitleTrack(SubStreamModel? model, PlaybackModel playbackModel) async => - await _player?.setSubtitleTrack(model, playbackModel) ?? -1; + Future setSubtitleTrack(SubStreamModel? model, PlaybackModel playbackModel) async { + final result = await _player?.setSubtitleTrack(model, playbackModel) ?? -1; + // Notify UI listeners that subtitle source/mode changed so subtitles can refresh + try { + subtitleChangeNotifier.value = subtitleChangeNotifier.value + 1; + } catch (_) {} + return result; + } Future setVolume(double volume) async => _player?.setVolume(volume); diff --git a/lib/wrappers/players/tizen_player.dart b/lib/wrappers/players/tizen_player.dart new file mode 100644 index 000000000..09341e018 --- /dev/null +++ b/lib/wrappers/players/tizen_player.dart @@ -0,0 +1,344 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:video_player_videohole/video_player.dart'; +import 'package:subtitle/subtitle.dart'; + +import 'package:fladder/models/settings/subtitle_settings_model.dart'; +import 'package:fladder/providers/settings/subtitle_settings_provider.dart'; +import 'package:fladder/util/subtitle_position_calculator.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'; + +class TizenPlayer extends BasePlayer { + VideoPlayerController? _controller; + final StreamController _stateController = StreamController.broadcast(); + + SubtitleController? _externalSubtitleController; + bool _externalSubEnabled = false; + + @override + Stream get stateStream => _stateController.stream; + + @override + Future init(VideoPlayerSettingsModel settings) async => await dispose(); + + @override + Future dispose() async { + await _controller?.dispose(); + _controller = null; + } + + @override + Future loadVideo(String url, bool play) async { + await _controller?.dispose(); + _controller = null; + + final validUrl = Uri.tryParse(url)?.isAbsolute ?? false; + _controller = validUrl + ? VideoPlayerController.network(url) + : VideoPlayerController.file(File(url)); + + _controller!.addListener(_updateState); + await _controller!.initialize(); + + if (play) await _controller!.play(); + + _updateState(); + } + + void _updateState() { + if (_controller == null) return; + + final value = _controller!.value; + + _stateController.add( + lastState.update( + playing: value.isPlaying, + completed: value.position >= value.duration.end, + position: value.position, + duration: value.duration.end, + volume: (value.volume) * 100, + rate: value.playbackSpeed, + buffering: value.isBuffering, + buffer: Duration(milliseconds: value.buffered), + ), + ); + } + + @override + Future open(BuildContext context) async { + await Navigator.of(context, rootNavigator: true).push( + MaterialPageRoute(builder: (_) => const video_screen.VideoPlayer()), + ); + } + + @override + Future pause() => _controller?.pause() ?? Future.value(); + @override + Future play() => _controller?.play() ?? Future.value(); + @override + Future playOrPause() async => lastState.playing ? _controller?.pause() : _controller?.play(); + @override + Future seek(Duration position) async => _controller?.seekTo(position); + @override + Future stop() async => _controller?.pause(); + @override + Future setVolume(double volume) async => _controller?.setVolume(volume / 100); + @override + Future loop(bool loop) async => _controller?.setLooping(loop); + + @override + Future setAudioTrack(AudioStreamModel? model, PlaybackModel playbackModel) async { + final wantedAudioStream = model ?? playbackModel.defaultAudioStream; + if (wantedAudioStream == AudioStreamModel.no() || wantedAudioStream == null) { + return -1; + } else { + final indexOf = playbackModel.audioStreams?.indexOf(wantedAudioStream); + final tracks = await _controller?.audioTracks; + if (tracks != null && tracks.isNotEmpty) { + if (indexOf != null) { + final track = tracks.elementAt((indexOf - 1)); + _controller?.setTrackSelection(track); + _controller?.play(); // refresh captions + } + return wantedAudioStream.index; + } + } + return -1; + } + + @override + Future setSubtitleTrack( + SubStreamModel? model, + PlaybackModel playbackModel, + ) async { + final wanted = model ?? playbackModel.defaultSubStream; + + if (wanted == null || wanted == SubStreamModel.no()) { + _externalSubEnabled = true; + _externalSubtitleController = null; + return -1; + } + + if (wanted.isExternal && wanted.url != null) { + loadExternalSubtitle(wanted.url!); + _controller?.play(); // refresh captions + return wanted.index; + } + + // Internal subtitle + _externalSubEnabled = false; + + final tracks = await _controller?.textTracks; + if (tracks != null && tracks.isNotEmpty) { + final indexOf = playbackModel.subStreams?.indexOf(wanted); + if (indexOf != null) { + if (indexOf - 1 >= 0) { + final track = tracks.elementAt(indexOf - 1); + _controller?.setTrackSelection(track); + _controller?.play(); // refresh captions + } else { + //No subtitles + _externalSubEnabled = true; + _externalSubtitleController = null; + _controller!.play(); + + } + } + } + return wanted.index; + } + + + Future loadExternalSubtitle( + String data, + ) async { + _externalSubEnabled = true; + _controller!.play(); + final provider = SubtitleProvider.fromNetwork( + Uri.tryParse(data) ?? Uri.parse('') + ); + + final controller = SubtitleController(provider: provider); + controller.initial(); + _externalSubtitleController = controller; + } + + @override + Future setSpeed(double speed) async => _controller?.setPlaybackSpeed(speed); + + @override + Future takeScreenshot() async => null; + + + @override + Widget? videoWidget( + Key key, + BoxFit fit, + ) => + _controller == null + ? null + : Container( + key: key, + color: Colors.transparent, + child: LayoutBuilder( + builder: (context, constraints) => Stack( + fit: StackFit.expand, + children: [ + FittedBox( + fit: fit, + alignment: Alignment.center, + child: ValueListenableBuilder( + valueListenable: _controller!, + builder: (context, value, child) { + final aspectRatio = value.isInitialized ? value.aspectRatio : 1.77; + final scale = View.of(context).devicePixelRatio / MediaQuery.devicePixelRatioOf(context); + return SizedBox( + width: constraints.maxWidth * scale, + child: AspectRatio( + aspectRatio: aspectRatio, + child: VideoPlayer(_controller!), + ), + ); + }, + ), + ), + ], + ), + ), + ); + + @override + Widget? subtitles(bool showOverlay, {GlobalKey? controlsKey}) { + if (_controller == null) return null; + + return _TizenSubtitles( + controller: _controller!, + showOverlay: showOverlay, + controlsKey: controlsKey, + externalSubtitleController: _externalSubtitleController, + useExternal: _externalSubEnabled, + ); + } +} + +class _TizenSubtitles extends ConsumerStatefulWidget { + final VideoPlayerController controller; + final bool showOverlay; + final GlobalKey? controlsKey; + + final SubtitleController? externalSubtitleController; + final bool useExternal; + + const _TizenSubtitles({ + required this.controller, + this.showOverlay = false, + this.controlsKey, + this.externalSubtitleController, + required this.useExternal, + }); + + @override + _TizenSubtitlesState createState() => _TizenSubtitlesState(); +} + +class _TizenSubtitlesState extends ConsumerState<_TizenSubtitles> { + String _cachedSubtitleText = ''; + String? _lastCaption; + double? _cachedMenuHeight; + + @override + void initState() { + super.initState(); + widget.controller.addListener(_onControllerUpdate); + } + + @override + void dispose() { + widget.controller.removeListener(_onControllerUpdate); + super.dispose(); + } + + void _onControllerUpdate() { + if (!mounted) return; + if (widget.controller.value.isInitialized == false) return; + + final position = widget.controller.value.position; + + String subtitle = ''; + + if (widget.useExternal && widget.externalSubtitleController != null && widget.externalSubtitleController!.initialized) { + subtitle = widget.externalSubtitleController?.durationSearch(position)?.data.trim() ?? ''; + } else if (!widget.useExternal) { + subtitle = widget.controller.value.caption.text.trim(); + } + + subtitle = _sanitizeSubtitle(subtitle); + + if (subtitle != _lastCaption) { + setState(() { + _lastCaption = subtitle; + _cachedSubtitleText = subtitle; + }); + } + } + + String _sanitizeSubtitle(String subtitle) { + if (subtitle.isEmpty) return subtitle; + + final fontTagRegex = RegExp(r']*>', caseSensitive: false); + final sanitized = subtitle.replaceAll(fontTagRegex, ''); + + return sanitized.trim(); + } + + @override + Widget build(BuildContext context) { + _measureMenuHeight(); + + final settings = ref.watch(subtitleSettingsProvider); + final padding = MediaQuery.paddingOf(context); + + final text = _cachedSubtitleText; + + if (text.isEmpty) return const SizedBox.shrink(); + + final offset = SubtitlePositionCalculator.calculateOffset( + settings: settings, + showOverlay: widget.showOverlay, + screenHeight: MediaQuery.sizeOf(context).height, + menuHeight: _cachedMenuHeight, + ); + + return SubtitleText( + subModel: settings, + padding: padding, + offset: offset, + text: text, + ); + } + + void _measureMenuHeight() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || widget.controlsKey == null) return; + + final RenderBox? renderBox = widget.controlsKey?.currentContext?.findRenderObject() as RenderBox?; + final newHeight = renderBox?.size.height; + + if (newHeight != _cachedMenuHeight && newHeight != null) { + setState(() { + _cachedMenuHeight = newHeight; + }); + } + }); + } +} diff --git a/pubspec.lock b/pubspec.lock index 34cac7b84..0ec2ae9db 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -337,6 +337,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.1" + connectivity_plus_tizen: + dependency: "direct main" + description: + name: connectivity_plus_tizen + sha256: "7f4ede84d702af8b3f56dbfb880e6772f3cf39e0498335753302db93aa562b11" + url: "https://pub.dev" + source: hosted + version: "1.2.2" convert: dependency: transitive description: @@ -465,6 +473,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.0" + device_info_plus_tizen: + dependency: "direct main" + description: + name: device_info_plus_tizen + sha256: "54cea0bb09f772e35cfabcf5f2920a8e615f98e6eecba9f102ea4a8762adad44" + url: "https://pub.dev" + source: hosted + version: "1.3.0" diffutil_dart: dependency: transitive description: @@ -505,6 +521,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.7" + drift_sqflite: + dependency: "direct main" + description: + name: drift_sqflite + sha256: dd1afbd72555b7a72ebf053926078d8c302059af4f1eb22040fc27a056429acb + url: "https://pub.dev" + source: hosted + version: "2.0.1" drift_sync: dependency: "direct main" description: @@ -784,6 +808,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_tizen: + dependency: "direct main" + description: + name: flutter_tizen + sha256: "5df51a022828127d8d3bf2af32e67b746b764b1209193148fd33b0d7b7c122a7" + url: "https://pub.dev" + source: hosted + version: "0.2.7" flutter_typeahead: dependency: "direct main" description: @@ -1365,6 +1397,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.1" + package_info_plus_tizen: + dependency: "direct main" + description: + name: package_info_plus_tizen + sha256: "5e85dd353e08b8a0c4ec52fdda8da6b591344b1440956b720553c52a5cb57b7e" + url: "https://pub.dev" + source: hosted + version: "1.0.5" page_transition: dependency: "direct main" description: @@ -1437,6 +1477,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + path_provider_tizen: + dependency: "direct main" + description: + name: path_provider_tizen + sha256: d896d52410f4b84c5673dadec2ed188104efb39243dd743a470d5d06cebb096f + url: "https://pub.dev" + source: hosted + version: "2.2.1" path_provider_windows: dependency: transitive description: @@ -1805,6 +1853,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.1" + shared_preferences_tizen: + dependency: "direct main" + description: + name: shared_preferences_tizen + sha256: fbf7194c2f6d97c68e254257172bb44a3a34a6ecfaceeedd208b5bf69d836b21 + url: "https://pub.dev" + source: hosted + version: "2.3.2" shared_preferences_web: dependency: transitive description: @@ -1883,7 +1939,7 @@ packages: source: hosted version: "1.10.1" sqflite: - dependency: transitive + dependency: "direct main" description: name: sqflite sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 @@ -1922,8 +1978,16 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.0" + sqflite_tizen: + dependency: "direct main" + description: + name: sqflite_tizen + sha256: fb144054bd7ebccbb4737eea0f948f6b5e6b45a832f797c3fbd3792d08aeefee + url: "https://pub.dev" + source: hosted + version: "0.1.3" sqlite3: - dependency: transitive + dependency: "direct main" description: name: sqlite3 sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2" @@ -1931,13 +1995,13 @@ packages: source: hosted version: "2.9.4" sqlite3_flutter_libs: - dependency: transitive + dependency: "direct main" description: name: sqlite3_flutter_libs - sha256: "69c80d812ef2500202ebd22002cbfc1b6565e9ff56b2f971e757fac5d42294df" + sha256: "1e800ebe7f85a80a66adacaa6febe4d5f4d8b75f244e9838a27cb2ffc7aec08d" url: "https://pub.dev" source: hosted - version: "0.5.40" + version: "0.5.41" sqlparser: dependency: transitive description: @@ -2002,6 +2066,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + subtitle: + dependency: "direct main" + description: + name: subtitle + sha256: "29115fbaa5d87c5909ffa477cbfdeec2c6ee8ac9a5ef3dce9b2db731afc79f26" + url: "https://pub.dev" + source: hosted + version: "0.1.4" swagger_dart_code_generator: dependency: "direct dev" description: @@ -2042,6 +2114,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" + tizen_app_control: + dependency: transitive + description: + name: tizen_app_control + sha256: "1822a0815434db29b0ac3cf8c37c4d4ad33da08704f68701a926bc99550ccda1" + url: "https://pub.dev" + source: hosted + version: "0.2.3" + tizen_interop: + dependency: transitive + description: + name: tizen_interop + sha256: "46303c36a7e45491ce0149c77a3f54f3d57654938983c482ab0363a8ebe6ee27" + url: "https://pub.dev" + source: hosted + version: "0.3.0" transparent_image: dependency: "direct main" description: @@ -2070,18 +2158,18 @@ packages: dependency: "direct main" description: name: universal_html - sha256: c0bcae5c733c60f26c7dfc88b10b0fd27cbcc45cb7492311cdaa6067e21c9cd4 + sha256: "0a1795dc516ed9fc48c6fdee8e9a99445cdce6871845d34e31c13184d7a743aa" url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "2.2.5" universal_io: dependency: transitive description: name: universal_io - sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2 + sha256: ba9dde5f7c6d8ec7ed856bef0eb01c425881acae7748f77dac4d5cc46f2edf04 url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.2.3" universal_platform: dependency: transitive description: @@ -2146,6 +2234,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + url_launcher_tizen: + dependency: "direct main" + description: + name: url_launcher_tizen + sha256: c959c3d77b3bff18258faf36dd1f8f647ddac56549860fcf5b2894c46f3989d8 + url: "https://pub.dev" + source: hosted + version: "2.1.4" url_launcher_web: dependency: transitive description: @@ -2242,6 +2338,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.6.0" + video_player_videohole: + dependency: "direct main" + description: + name: video_player_videohole + sha256: "4897cddd805b329c8236057cf554aa979d79aba4d1d85d9779fe572edbb56555" + url: "https://pub.dev" + source: hosted + version: "0.5.8" video_player_web: dependency: transitive description: @@ -2282,6 +2386,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + wakelock_plus_tizen: + dependency: "direct main" + description: + name: wakelock_plus_tizen + sha256: "12bb99ccc5503bdc98d850b6fb01ac424e05d78c9805dc1c80d01c5d73a70a00" + url: "https://pub.dev" + source: hosted + version: "2.0.0" watcher: dependency: transitive description: @@ -2346,6 +2458,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.14.0" + webview_flutter_tizen: + dependency: "direct main" + description: + name: webview_flutter_tizen + sha256: "174b10f846f613da6327e8f11dea3a69f55d69edea47625e576de6182dfb8320" + url: "https://pub.dev" + source: hosted + version: "0.9.7" webview_flutter_wkwebview: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 61d7e21a9..ef68dcd0d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -124,6 +124,24 @@ dependencies: drift_sync: ^0.14.0 drift_db_viewer: ^2.1.0 + # Tizen-specific pl + + + Exe + tizen80 + + + + + + + + + + %(RecursiveDir) + + + + diff --git a/tizen/shared/res/fladder_icon.png b/tizen/shared/res/fladder_icon.png new file mode 100644 index 000000000..a6d88be00 Binary files /dev/null and b/tizen/shared/res/fladder_icon.png differ diff --git a/tizen/tizen-manifest.xml b/tizen/tizen-manifest.xml new file mode 100644 index 000000000..8b0a2bfe7 --- /dev/null +++ b/tizen/tizen-manifest.xml @@ -0,0 +1,26 @@ + + + + + + fladder_icon.png + + + + + + http://developer.samsung.com/privilege/productinfo + http://tizen.org/privilege/internet + http://tizen.org/privilege/mediastorage + http://tizen.org/privilege/externalstorage + http://developer.samsung.com/privilege/drmplay + http://tizen.org/privilege/content.write + http://tizen.org/privilege/content.read + http://tizen.org/privilege/tv.channel + http://tizen.org/privilege/tv.display + http://tizen.org/privilege/tv.window + http://tizen.org/privilege/tv.inputdevice + http://tizen.org/privilege/network.get + http://tizen.org/privilege/appmanager.launch + +