From 50fdce51574541dfdeb3d9395af7cf718eea7b75 Mon Sep 17 00:00:00 2001 From: omkar Date: Thu, 30 Jan 2025 21:58:43 +0530 Subject: [PATCH] fix: prerelease --- README.md | 5 + android/app/build.gradle | 2 + assets/data/regions.json | 239 +++ assets/data/tmdb_language.json | 937 ++++++++++ ios/Podfile.lock | 13 - lib/app/app.dart | 55 + lib/app/app_router.dart | 239 +++ .../season_selector.dart => app/app_web.dart} | 6 +- lib/consts/data.dart | 19 + lib/data/db.dart | 55 + lib/data/tables/ratings.dart | 10 + lib/database/app_database.dart | 30 - lib/database/database_provider.dart | 13 - .../quries/watch_history_queries.dart | 42 - lib/database/tables/watch_history_table.dart | 15 - lib/engine/connection.dart | 34 - lib/engine/connection_type.dart | 67 - lib/engine/engine.dart | 48 - lib/engine/library.dart | 139 -- lib/extension/image_to_bytes.dart | 27 - .../accounts/container/trakt.container.dart | 184 ++ .../accounts/pages/external_account.dart | 54 + .../auth/pages/forget_password_page.dart | 205 +++ lib/features/auth/pages/signin_page.dart | 517 ++++++ lib/features/auth/pages/signup_page.dart | 538 ++++++ lib/features/auth/service/layout_service.dart | 56 + lib/features/chat/container/chat_action.dart | 155 -- lib/features/chat/container/chat_bubble.dart | 158 -- .../chat/container/chat_container.dart | 150 -- .../chat/container/chat_empty_state.dart | 207 --- lib/features/chat/container/chat_history.dart | 157 -- .../chat/container/chat_input_area.dart | 328 ---- .../container/add_collection_item.dart | 217 --- .../container/collection_item_renderer.dart | 25 - .../container/collection_list_item_list.dart | 237 --- .../collection_markdown_renderer.dart | 155 -- .../container/collection_search_delegate.dart | 149 -- .../container/create_new_collection.dart | 147 -- lib/features/collection/service/service.dart | 116 -- .../types/collection_item_model.dart | 43 - .../collection/widgets/collection_card.dart | 313 ---- lib/features/common/utils/error_handler.dart | 71 + .../common/utils/refresh_auth.dart} | 6 +- lib/features/common/utils/startup_app.dart | 42 + .../connection/containers/auto_import.dart | 201 --- .../containers/configure_neo_connection.dart | 141 -- .../configure_stremio_connection.dart | 306 ---- .../containers/connection_manager.dart | 265 --- .../containers/create_new_connection.dart | 208 --- .../containers/folder_selector.dart | 170 -- .../show_handle_connection_type.dart | 94 - .../services/base_connection_service.dart | 37 - .../connection/services/stremio_service.dart | 266 --- lib/features/connection/types/stremio.dart | 1 - .../service/base_connection_service.dart | 271 --- .../service/stremio_connection_service.dart | 700 -------- lib/features/connections/types/base/base.dart | 34 - .../widget/base/render_library_list.dart | 609 ------- .../widget/base/render_stream_list.dart | 322 ---- .../widget/stremio/stremio_card.dart | 461 ----- .../widget/stremio/stremio_create.dart | 12 - .../widget/stremio/stremio_filter.dart | 130 -- .../widget/stremio/stremio_item_viewer.dart | 520 ------ .../stremio/stremio_item_viewer_card.dart | 476 ----- .../widget/stremio/stremio_list_item.dart | 17 - .../stremio/stremio_season_selector.dart | 473 ----- .../doc_viewer/container/doc_viewer.dart | 128 -- lib/features/doc_viewer/container/iframe.dart | 130 -- .../container/pdf/magic_bottom_sheet.dart | 98 -- .../pdf/magic_page_selector_bottom_sheet.dart | 134 -- .../container/pdf/magic_show_markdown.dart | 327 ---- .../container/pdf/markers_view.dart | 63 - .../container/pdf/outline_view.dart | 54 - .../container/pdf/password_dialog.dart | 34 - .../doc_viewer/container/pdf/search_view.dart | 376 ---- .../container/pdf/thumbnails_view.dart | 55 - .../doc_viewer/container/pdf_viewer.dart | 389 ----- .../doc_viewer/container/photo_viewer.dart | 35 - .../doc_viewer/container/video_viewer.dart | 422 ----- .../video_viewer/audio_track_selector.dart | 85 - .../video_viewer/desktop_video_player.dart | 209 --- .../container/video_viewer/season_source.dart | 222 --- .../video_viewer/subtitle_selector.dart | 207 --- .../container/video_viewer/torrent_stat.dart | 527 ------ .../video_viewer/trakt_integration.dart | 13 - .../container/video_viewer/tv_controls.dart | 1525 ----------------- .../video_viewer/video_viewer_mobile_ui.dart | 276 --- .../video_viewer/video_viewer_ui.dart | 308 ---- lib/features/doc_viewer/types/doc_source.dart | 365 ---- lib/features/doc_viewer/utils/get_types.dart | 50 - lib/features/downloads/container/index.dart | 324 ---- .../downloads/pages/downloads_page.dart | 192 +++ .../downloads/service/download_service.dart | 82 + lib/features/downloads/service/service.dart | 72 - .../explore/containers/explore_addon.dart | 348 ++++ lib/features/explore/pages/explore.page.dart | 74 + .../files/container/file.container.dart | 169 -- .../container/create_connection.dart | 526 ------ .../container/getting_started.dart | 248 --- lib/features/home/pages/home_page.dart | 189 ++ lib/features/home/screen/home_items.dart | 318 ---- .../layout/data/navigation_items.dart | 36 + lib/features/layout/models/device_type.dart | 1 + .../layout/models/navigation.model.dart | 15 + .../layout/widgets/desktop_navigation.dart | 61 + .../layout/widgets/mobile_navigation.dart | 43 + .../layout/widgets/scaffold_with_nav.dart | 122 ++ .../layout/widgets/tv_navigation.dart | 76 + .../library/component/library_search.dart | 272 --- .../library/component/libray_card.dart | 58 - .../library/container/add_to_list_button.dart | 446 +++++ .../library/container/create_list_widget.dart | 235 +++ .../library/containers/connection_list.dart | 180 -- lib/features/library/pages/library.page.dart | 258 +++ .../library/pages/list_detail_page.dart | 378 ++++ .../library/screen/create_new_library.dart | 293 ---- .../library/screen/library_screen.dart | 94 - .../library/service/list_service.dart | 114 ++ .../library/service/trakt_service.dart | 237 +++ lib/features/library/types/library_item.dart | 1 - lib/features/library/types/library_types.dart | 148 ++ .../library_item/container/item_list.dart | 528 ------ .../library_item/container/item_viewer.dart | 167 -- .../container/stremio_item_card.dart | 110 -- .../container/stremio_item_list.dart | 130 -- .../stremio_item_season_selector.dart | 201 --- .../container/stremio_item_viewer.dart | 467 ----- .../container/stremio_stream_selector.dart | 313 ---- .../logger/data/global_logs.data.dart} | 0 .../logger/service/logger.service.dart | 36 + .../meta/pages/meta_page.dart} | 0 .../offline_ratings/models/rating_model.dart | 19 + .../pages/offline_ratings.dart | 279 +++ .../services/ratings_service.dart | 162 ++ .../playlist/service/playlist_service.dart | 146 -- lib/features/playlist/types/playlist.dart | 41 - .../playlist/types/playlist_item.dart | 49 - .../service/pocketbase.service.dart | 41 + lib/features/search/pages/search_page.dart | 1 + .../settings/model/external_media_player.dart | 9 + .../model/playback_settings_model.dart | 47 + .../navigation/account_navigation.dart | 52 - .../settings/pages/appearance_page.dart | 211 +++ .../settings/pages/change_password_page.dart | 276 +++ .../settings/pages/connections_page.dart | 1 + .../pages/debug/clear_cache_page.dart | 1 + .../settings/pages/debug/logs_page.dart | 248 +++ .../settings/pages/full_profile_selector.dart | 333 ++++ lib/features/settings/pages/layout_page.dart | 544 ++++++ .../pages/playback_settings_page.dart | 382 +++++ lib/features/settings/pages/profile_page.dart | 341 ++++ .../pages/settings/profile_selector.dart | 223 +++ .../settings/pages/settings_page.dart | 261 +++ .../settings/pages/subprofiles_page.dart | 475 +++++ .../settings/screen/account_screen.dart | 89 - .../settings/screen/connection_screen.dart | 78 - .../screen/email_settings_screen.dart | 57 - lib/features/settings/screen/help_screen.dart | 53 - lib/features/settings/screen/logs_screen.dart | 194 --- .../settings/screen/notification_screen.dart | 57 - .../settings/screen/payment_screen.dart | 48 - .../screen/playback_settings_screen.dart | 436 ----- .../settings/screen/profile_button.dart | 32 - .../settings/screen/profile_setting.dart | 223 --- .../settings/screen/screen_proxy_setting.dart | 179 -- .../settings/screen/security_screen.dart | 251 --- .../screen/trakt_integration_screen.dart | 324 ---- .../service/account_profile_service.dart | 111 ++ .../settings/service/external_players.dart | 62 + .../service/playback_setting_service.dart | 86 + .../settings/service/selected_profile.dart | 51 + lib/features/settings/types/connection.dart | 38 - lib/features/settings/types/user_profile.dart | 30 - .../settings/widget/language_selector.dart | 99 ++ .../settings/widget/profile_dialog.dart | 208 +++ .../settings/widget/region_selector.dart | 99 ++ .../widget/searchable_language_dropdown.dart | 157 ++ .../settings/widget/setting_wrapper.dart | 29 + .../extension/query_extension.dart | 15 + .../models/stremio_base_types.dart} | 181 +- .../pages/stremio_addons_page.dart | 241 +++ .../service/stremio_addon_service.dart | 570 ++++++ .../widget/add_addon_sheet.dart | 180 ++ .../widget/manifest_preview.dart | 137 ++ .../widget/stremio_addons_list.dart | 308 ++++ .../theme/provider/theme_provider.dart | 59 + .../service/theme_preferences.service.dart | 43 + lib/features/theme/theme/app_theme.dart | 31 + lib/features/theme/utils/color_utils.dart | 53 + .../trakt/containers/up_next.container.dart | 361 ---- lib/features/trakt/service/trakt.service.dart | 1081 ------------ lib/features/trakt/types/common.dart | 15 - .../container/options/always_on_top.dart | 69 + .../options/audio_track_selector.dart | 82 + .../container/options/delay_controls.dart | 58 + .../container/options/playback_speed.dart | 47 + .../container/options/scale_option.dart | 27 + .../container/options/settings_sheet.dart | 193 +++ .../container/options/subtitle_selector.dart | 67 + .../container/options/subtitle_size.dart | 65 + .../options/subtitle_stylesheet.dart | 129 ++ .../container/state/video_settings.dart | 64 + .../video_player/container/video_desktop.dart | 200 +++ .../video_player/container/video_mobile.dart | 222 +++ .../video_player/container/video_play.dart | 88 + .../video_player/container/video_player.dart | 202 +++ .../service/base_watch_history.dart | 46 - .../service/zeee_watch_history.dart | 255 --- lib/features/widgetter/interface/widgets.dart | 3 + lib/features/widgetter/plugin_base.dart | 112 ++ lib/features/widgetter/plugin_layout.dart | 159 ++ .../plugins/stremio/containers/cast_info.dart | 430 +++++ .../stremio/containers/cast_info_shimmer.dart | 257 +++ .../stremio/containers/keyboard_handler.dart | 66 + .../plugins/stremio/containers/shimmer.dart | 304 ++++ .../stremio/containers/stream_list.dart | 573 +++++++ .../containers/streamio_background.dart | 765 +++++++++ .../containers/streamio_cast_section.dart | 171 ++ .../containers/streamio_episode_list.dart | 321 ++++ .../containers/streamio_trailer_section.dart | 168 ++ .../containers/streamio_video_list.dart | 54 + .../containers/streamio_viewer_content.dart | 159 ++ .../plugins/stremio/models/cast_info.dart | 94 + .../stremio/pages/streamio_item_viewer.dart | 174 ++ .../plugins/stremio/stremio_plugin.dart | 80 + .../widgetter/plugins/stremio/utils/size.dart | 52 + .../stremio/widgets/catalog_featured.dart | 350 ++++ .../widgets/catalog_featured_shimmer.dart | 69 + .../plugins/stremio/widgets/catalog_grid.dart | 559 ++++++ .../stremio/widgets/catalog_grid_full.dart | 131 ++ .../plugins/stremio/widgets/error_card.dart | 48 + .../plugins/stremio/widgets/stremio_card.dart | 480 ++++++ .../service/home_layout_service.dart | 180 ++ .../state/widget_state_provider.dart | 37 + .../widgetter/types/home_layout_model.dart | 35 + .../widgetter/types/widget_gallery.dart | 37 + lib/features/zeku/models/integration.dart | 7 + lib/features/zeku/pages/integration_page.dart | 24 + lib/main.dart | 192 +-- lib/main_web.dart | 82 +- lib/pages/category.page.dart | 29 - lib/pages/chat.container.dart | 251 --- lib/pages/collection_tab.page.dart | 334 ---- lib/pages/download.page.dart | 374 ---- lib/pages/getting_started.page.dart | 19 - lib/pages/home.page.dart | 232 --- lib/pages/home_tab.page.dart | 338 ---- lib/pages/home_tv.page.dart | 91 - lib/pages/library_view.page.dart | 12 - lib/pages/more_tab.page.dart | 183 -- lib/pages/search_tab.page.dart | 201 --- lib/pages/sign_in.page.dart | 345 ---- lib/pages/sign_up.page.dart | 389 ----- lib/pages/stremio_item.page.dart | 187 -- lib/routes.dart | 139 -- .../{common.dart => array-extension.dart} | 0 lib/utils/cached_storage_static.dart | 5 - lib/utils/external_player.dart | 120 -- lib/utils/grid.dart | 31 - lib/utils/load_language.dart | 83 - lib/utils/ocr_file.dart | 33 - lib/utils/sse_stream.dart | 7 - lib/utils/sse_stream_web.dart | 8 - lib/utils/stream_base.dart | 1 - lib/utils/tv_detector.dart | 5 - lib/widgets/base64_image.dart | 69 - lib/widgets/bottom_sheet.dart | 78 - linux/flutter/generated_plugins.cmake | 1 - macos/Flutter/GeneratedPluginRegistrant.swift | 2 - macos/Podfile.lock | 13 - pubspec.lock | 86 +- pubspec.yaml | 9 +- test/stremio_connection_service_test.dart | 42 - test/trakt_service_test.dart | 11 - test/widget_test.dart | 30 - windows/flutter/generated_plugins.cmake | 1 - 276 files changed, 20339 insertions(+), 27348 deletions(-) create mode 100644 assets/data/regions.json create mode 100644 assets/data/tmdb_language.json create mode 100644 lib/app/app.dart create mode 100644 lib/app/app_router.dart rename lib/{features/doc_viewer/container/video_viewer/season_selector.dart => app/app_web.dart} (55%) create mode 100644 lib/consts/data.dart create mode 100644 lib/data/db.dart create mode 100644 lib/data/tables/ratings.dart delete mode 100644 lib/database/app_database.dart delete mode 100644 lib/database/database_provider.dart delete mode 100644 lib/database/quries/watch_history_queries.dart delete mode 100644 lib/database/tables/watch_history_table.dart delete mode 100644 lib/engine/connection.dart delete mode 100644 lib/engine/connection_type.dart delete mode 100644 lib/engine/engine.dart delete mode 100644 lib/engine/library.dart delete mode 100644 lib/extension/image_to_bytes.dart create mode 100644 lib/features/accounts/container/trakt.container.dart create mode 100644 lib/features/accounts/pages/external_account.dart create mode 100644 lib/features/auth/pages/forget_password_page.dart create mode 100644 lib/features/auth/pages/signin_page.dart create mode 100644 lib/features/auth/pages/signup_page.dart create mode 100644 lib/features/auth/service/layout_service.dart delete mode 100644 lib/features/chat/container/chat_action.dart delete mode 100644 lib/features/chat/container/chat_bubble.dart delete mode 100644 lib/features/chat/container/chat_container.dart delete mode 100644 lib/features/chat/container/chat_empty_state.dart delete mode 100644 lib/features/chat/container/chat_history.dart delete mode 100644 lib/features/chat/container/chat_input_area.dart delete mode 100644 lib/features/collection/container/add_collection_item.dart delete mode 100644 lib/features/collection/container/collection_item_renderer.dart delete mode 100644 lib/features/collection/container/collection_list_item_list.dart delete mode 100644 lib/features/collection/container/collection_markdown_renderer.dart delete mode 100644 lib/features/collection/container/collection_search_delegate.dart delete mode 100644 lib/features/collection/container/create_new_collection.dart delete mode 100644 lib/features/collection/service/service.dart delete mode 100644 lib/features/collection/types/collection_item_model.dart delete mode 100644 lib/features/collection/widgets/collection_card.dart create mode 100644 lib/features/common/utils/error_handler.dart rename lib/{utils/auth_refresh.dart => features/common/utils/refresh_auth.dart} (53%) create mode 100644 lib/features/common/utils/startup_app.dart delete mode 100644 lib/features/connection/containers/auto_import.dart delete mode 100644 lib/features/connection/containers/configure_neo_connection.dart delete mode 100644 lib/features/connection/containers/configure_stremio_connection.dart delete mode 100644 lib/features/connection/containers/connection_manager.dart delete mode 100644 lib/features/connection/containers/create_new_connection.dart delete mode 100644 lib/features/connection/containers/folder_selector.dart delete mode 100644 lib/features/connection/containers/show_handle_connection_type.dart delete mode 100644 lib/features/connection/services/base_connection_service.dart delete mode 100644 lib/features/connection/services/stremio_service.dart delete mode 100644 lib/features/connection/types/stremio.dart delete mode 100644 lib/features/connections/service/base_connection_service.dart delete mode 100644 lib/features/connections/service/stremio_connection_service.dart delete mode 100644 lib/features/connections/types/base/base.dart delete mode 100644 lib/features/connections/widget/base/render_library_list.dart delete mode 100644 lib/features/connections/widget/base/render_stream_list.dart delete mode 100644 lib/features/connections/widget/stremio/stremio_card.dart delete mode 100644 lib/features/connections/widget/stremio/stremio_create.dart delete mode 100644 lib/features/connections/widget/stremio/stremio_filter.dart delete mode 100644 lib/features/connections/widget/stremio/stremio_item_viewer.dart delete mode 100644 lib/features/connections/widget/stremio/stremio_item_viewer_card.dart delete mode 100644 lib/features/connections/widget/stremio/stremio_list_item.dart delete mode 100644 lib/features/connections/widget/stremio/stremio_season_selector.dart delete mode 100644 lib/features/doc_viewer/container/doc_viewer.dart delete mode 100644 lib/features/doc_viewer/container/iframe.dart delete mode 100644 lib/features/doc_viewer/container/pdf/magic_bottom_sheet.dart delete mode 100644 lib/features/doc_viewer/container/pdf/magic_page_selector_bottom_sheet.dart delete mode 100644 lib/features/doc_viewer/container/pdf/magic_show_markdown.dart delete mode 100644 lib/features/doc_viewer/container/pdf/markers_view.dart delete mode 100644 lib/features/doc_viewer/container/pdf/outline_view.dart delete mode 100644 lib/features/doc_viewer/container/pdf/password_dialog.dart delete mode 100644 lib/features/doc_viewer/container/pdf/search_view.dart delete mode 100644 lib/features/doc_viewer/container/pdf/thumbnails_view.dart delete mode 100644 lib/features/doc_viewer/container/pdf_viewer.dart delete mode 100644 lib/features/doc_viewer/container/photo_viewer.dart delete mode 100644 lib/features/doc_viewer/container/video_viewer.dart delete mode 100644 lib/features/doc_viewer/container/video_viewer/audio_track_selector.dart delete mode 100644 lib/features/doc_viewer/container/video_viewer/desktop_video_player.dart delete mode 100644 lib/features/doc_viewer/container/video_viewer/season_source.dart delete mode 100644 lib/features/doc_viewer/container/video_viewer/subtitle_selector.dart delete mode 100644 lib/features/doc_viewer/container/video_viewer/torrent_stat.dart delete mode 100644 lib/features/doc_viewer/container/video_viewer/trakt_integration.dart delete mode 100644 lib/features/doc_viewer/container/video_viewer/tv_controls.dart delete mode 100644 lib/features/doc_viewer/container/video_viewer/video_viewer_mobile_ui.dart delete mode 100644 lib/features/doc_viewer/container/video_viewer/video_viewer_ui.dart delete mode 100644 lib/features/doc_viewer/types/doc_source.dart delete mode 100644 lib/features/doc_viewer/utils/get_types.dart delete mode 100644 lib/features/downloads/container/index.dart create mode 100644 lib/features/downloads/pages/downloads_page.dart create mode 100644 lib/features/downloads/service/download_service.dart delete mode 100644 lib/features/downloads/service/service.dart create mode 100644 lib/features/explore/containers/explore_addon.dart create mode 100644 lib/features/explore/pages/explore.page.dart delete mode 100644 lib/features/files/container/file.container.dart delete mode 100644 lib/features/getting_started/container/create_connection.dart delete mode 100644 lib/features/getting_started/container/getting_started.dart create mode 100644 lib/features/home/pages/home_page.dart delete mode 100644 lib/features/home/screen/home_items.dart create mode 100644 lib/features/layout/data/navigation_items.dart create mode 100644 lib/features/layout/models/device_type.dart create mode 100644 lib/features/layout/models/navigation.model.dart create mode 100644 lib/features/layout/widgets/desktop_navigation.dart create mode 100644 lib/features/layout/widgets/mobile_navigation.dart create mode 100644 lib/features/layout/widgets/scaffold_with_nav.dart create mode 100644 lib/features/layout/widgets/tv_navigation.dart delete mode 100644 lib/features/library/component/library_search.dart delete mode 100644 lib/features/library/component/libray_card.dart create mode 100644 lib/features/library/container/add_to_list_button.dart create mode 100644 lib/features/library/container/create_list_widget.dart delete mode 100644 lib/features/library/containers/connection_list.dart create mode 100644 lib/features/library/pages/library.page.dart create mode 100644 lib/features/library/pages/list_detail_page.dart delete mode 100644 lib/features/library/screen/create_new_library.dart delete mode 100644 lib/features/library/screen/library_screen.dart create mode 100644 lib/features/library/service/list_service.dart create mode 100644 lib/features/library/service/trakt_service.dart delete mode 100644 lib/features/library/types/library_item.dart create mode 100644 lib/features/library/types/library_types.dart delete mode 100644 lib/features/library_item/container/item_list.dart delete mode 100644 lib/features/library_item/container/item_viewer.dart delete mode 100644 lib/features/library_item/container/stremio_item_card.dart delete mode 100644 lib/features/library_item/container/stremio_item_list.dart delete mode 100644 lib/features/library_item/container/stremio_item_season_selector.dart delete mode 100644 lib/features/library_item/container/stremio_item_viewer.dart delete mode 100644 lib/features/library_item/container/stremio_stream_selector.dart rename lib/{data/global_logs.dart => features/logger/data/global_logs.data.dart} (100%) create mode 100644 lib/features/logger/service/logger.service.dart rename lib/{utils/sse_stream_stub.dart => features/meta/pages/meta_page.dart} (100%) create mode 100644 lib/features/offline_ratings/models/rating_model.dart create mode 100644 lib/features/offline_ratings/pages/offline_ratings.dart create mode 100644 lib/features/offline_ratings/services/ratings_service.dart delete mode 100644 lib/features/playlist/service/playlist_service.dart delete mode 100644 lib/features/playlist/types/playlist.dart delete mode 100644 lib/features/playlist/types/playlist_item.dart create mode 100644 lib/features/pocketbase/service/pocketbase.service.dart create mode 100644 lib/features/search/pages/search_page.dart create mode 100644 lib/features/settings/model/external_media_player.dart create mode 100644 lib/features/settings/model/playback_settings_model.dart delete mode 100644 lib/features/settings/navigation/account_navigation.dart create mode 100644 lib/features/settings/pages/appearance_page.dart create mode 100644 lib/features/settings/pages/change_password_page.dart create mode 100644 lib/features/settings/pages/connections_page.dart create mode 100644 lib/features/settings/pages/debug/clear_cache_page.dart create mode 100644 lib/features/settings/pages/debug/logs_page.dart create mode 100644 lib/features/settings/pages/full_profile_selector.dart create mode 100644 lib/features/settings/pages/layout_page.dart create mode 100644 lib/features/settings/pages/playback_settings_page.dart create mode 100644 lib/features/settings/pages/profile_page.dart create mode 100644 lib/features/settings/pages/settings/profile_selector.dart create mode 100644 lib/features/settings/pages/settings_page.dart create mode 100644 lib/features/settings/pages/subprofiles_page.dart delete mode 100644 lib/features/settings/screen/account_screen.dart delete mode 100644 lib/features/settings/screen/connection_screen.dart delete mode 100644 lib/features/settings/screen/email_settings_screen.dart delete mode 100644 lib/features/settings/screen/help_screen.dart delete mode 100644 lib/features/settings/screen/logs_screen.dart delete mode 100644 lib/features/settings/screen/notification_screen.dart delete mode 100644 lib/features/settings/screen/payment_screen.dart delete mode 100644 lib/features/settings/screen/playback_settings_screen.dart delete mode 100644 lib/features/settings/screen/profile_button.dart delete mode 100644 lib/features/settings/screen/profile_setting.dart delete mode 100644 lib/features/settings/screen/screen_proxy_setting.dart delete mode 100644 lib/features/settings/screen/security_screen.dart delete mode 100644 lib/features/settings/screen/trakt_integration_screen.dart create mode 100644 lib/features/settings/service/account_profile_service.dart create mode 100644 lib/features/settings/service/external_players.dart create mode 100644 lib/features/settings/service/playback_setting_service.dart create mode 100644 lib/features/settings/service/selected_profile.dart delete mode 100644 lib/features/settings/types/connection.dart delete mode 100644 lib/features/settings/types/user_profile.dart create mode 100644 lib/features/settings/widget/language_selector.dart create mode 100644 lib/features/settings/widget/profile_dialog.dart create mode 100644 lib/features/settings/widget/region_selector.dart create mode 100644 lib/features/settings/widget/searchable_language_dropdown.dart create mode 100644 lib/features/settings/widget/setting_wrapper.dart create mode 100644 lib/features/streamio_addons/extension/query_extension.dart rename lib/features/{connections/types/stremio/stremio_base.types.dart => streamio_addons/models/stremio_base_types.dart} (80%) create mode 100644 lib/features/streamio_addons/pages/stremio_addons_page.dart create mode 100644 lib/features/streamio_addons/service/stremio_addon_service.dart create mode 100644 lib/features/streamio_addons/widget/add_addon_sheet.dart create mode 100644 lib/features/streamio_addons/widget/manifest_preview.dart create mode 100644 lib/features/streamio_addons/widget/stremio_addons_list.dart create mode 100644 lib/features/theme/provider/theme_provider.dart create mode 100644 lib/features/theme/service/theme_preferences.service.dart create mode 100644 lib/features/theme/theme/app_theme.dart create mode 100644 lib/features/theme/utils/color_utils.dart delete mode 100644 lib/features/trakt/containers/up_next.container.dart delete mode 100644 lib/features/trakt/service/trakt.service.dart delete mode 100644 lib/features/trakt/types/common.dart create mode 100644 lib/features/video_player/container/options/always_on_top.dart create mode 100644 lib/features/video_player/container/options/audio_track_selector.dart create mode 100644 lib/features/video_player/container/options/delay_controls.dart create mode 100644 lib/features/video_player/container/options/playback_speed.dart create mode 100644 lib/features/video_player/container/options/scale_option.dart create mode 100644 lib/features/video_player/container/options/settings_sheet.dart create mode 100644 lib/features/video_player/container/options/subtitle_selector.dart create mode 100644 lib/features/video_player/container/options/subtitle_size.dart create mode 100644 lib/features/video_player/container/options/subtitle_stylesheet.dart create mode 100644 lib/features/video_player/container/state/video_settings.dart create mode 100644 lib/features/video_player/container/video_desktop.dart create mode 100644 lib/features/video_player/container/video_mobile.dart create mode 100644 lib/features/video_player/container/video_play.dart create mode 100644 lib/features/video_player/container/video_player.dart delete mode 100644 lib/features/watch_history/service/base_watch_history.dart delete mode 100644 lib/features/watch_history/service/zeee_watch_history.dart create mode 100644 lib/features/widgetter/interface/widgets.dart create mode 100644 lib/features/widgetter/plugin_base.dart create mode 100644 lib/features/widgetter/plugin_layout.dart create mode 100644 lib/features/widgetter/plugins/stremio/containers/cast_info.dart create mode 100644 lib/features/widgetter/plugins/stremio/containers/cast_info_shimmer.dart create mode 100644 lib/features/widgetter/plugins/stremio/containers/keyboard_handler.dart create mode 100644 lib/features/widgetter/plugins/stremio/containers/shimmer.dart create mode 100644 lib/features/widgetter/plugins/stremio/containers/stream_list.dart create mode 100644 lib/features/widgetter/plugins/stremio/containers/streamio_background.dart create mode 100644 lib/features/widgetter/plugins/stremio/containers/streamio_cast_section.dart create mode 100644 lib/features/widgetter/plugins/stremio/containers/streamio_episode_list.dart create mode 100644 lib/features/widgetter/plugins/stremio/containers/streamio_trailer_section.dart create mode 100644 lib/features/widgetter/plugins/stremio/containers/streamio_video_list.dart create mode 100644 lib/features/widgetter/plugins/stremio/containers/streamio_viewer_content.dart create mode 100644 lib/features/widgetter/plugins/stremio/models/cast_info.dart create mode 100644 lib/features/widgetter/plugins/stremio/pages/streamio_item_viewer.dart create mode 100644 lib/features/widgetter/plugins/stremio/stremio_plugin.dart create mode 100644 lib/features/widgetter/plugins/stremio/utils/size.dart create mode 100644 lib/features/widgetter/plugins/stremio/widgets/catalog_featured.dart create mode 100644 lib/features/widgetter/plugins/stremio/widgets/catalog_featured_shimmer.dart create mode 100644 lib/features/widgetter/plugins/stremio/widgets/catalog_grid.dart create mode 100644 lib/features/widgetter/plugins/stremio/widgets/catalog_grid_full.dart create mode 100644 lib/features/widgetter/plugins/stremio/widgets/error_card.dart create mode 100644 lib/features/widgetter/plugins/stremio/widgets/stremio_card.dart create mode 100644 lib/features/widgetter/service/home_layout_service.dart create mode 100644 lib/features/widgetter/state/widget_state_provider.dart create mode 100644 lib/features/widgetter/types/home_layout_model.dart create mode 100644 lib/features/widgetter/types/widget_gallery.dart create mode 100644 lib/features/zeku/models/integration.dart create mode 100644 lib/features/zeku/pages/integration_page.dart delete mode 100644 lib/pages/category.page.dart delete mode 100644 lib/pages/chat.container.dart delete mode 100644 lib/pages/collection_tab.page.dart delete mode 100644 lib/pages/download.page.dart delete mode 100644 lib/pages/getting_started.page.dart delete mode 100644 lib/pages/home.page.dart delete mode 100644 lib/pages/home_tab.page.dart delete mode 100644 lib/pages/home_tv.page.dart delete mode 100644 lib/pages/library_view.page.dart delete mode 100644 lib/pages/more_tab.page.dart delete mode 100644 lib/pages/search_tab.page.dart delete mode 100644 lib/pages/sign_in.page.dart delete mode 100644 lib/pages/sign_up.page.dart delete mode 100644 lib/pages/stremio_item.page.dart delete mode 100644 lib/routes.dart rename lib/utils/{common.dart => array-extension.dart} (100%) delete mode 100644 lib/utils/cached_storage_static.dart delete mode 100644 lib/utils/external_player.dart delete mode 100644 lib/utils/grid.dart delete mode 100644 lib/utils/load_language.dart delete mode 100644 lib/utils/ocr_file.dart delete mode 100644 lib/utils/sse_stream.dart delete mode 100644 lib/utils/sse_stream_web.dart delete mode 100644 lib/utils/stream_base.dart delete mode 100644 lib/utils/tv_detector.dart delete mode 100644 lib/widgets/base64_image.dart delete mode 100644 lib/widgets/bottom_sheet.dart delete mode 100644 test/stremio_connection_service_test.dart delete mode 100644 test/trakt_service_test.dart delete mode 100644 test/widget_test.dart diff --git a/README.md b/README.md index 474de3b..5240a1d 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,11 @@ An open-source media manager app built with Flutter, designed to stream videos f - **Cross-Platform Support**: Works on Android, iOS supported by Flutter. - **Open Source**: Contributions are welcome! + +## TODO + +- [ ] Update dialog + ## Screenshots diff --git a/android/app/build.gradle b/android/app/build.gradle index f4c874b..e05ec94 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -56,6 +56,8 @@ dependencies { implementation('com.google.mlkit:text-recognition-devanagari:16.0.1') implementation('com.google.mlkit:text-recognition-chinese:16.0.1') implementation('com.google.mlkit:text-recognition-korean:16.0.1') + implementation("androidx.tv:tv-foundation:1.0.0-alpha12") + implementation("androidx.tv:tv-material:1.1.0-alpha01") } flutter { diff --git a/assets/data/regions.json b/assets/data/regions.json new file mode 100644 index 0000000..57369dd --- /dev/null +++ b/assets/data/regions.json @@ -0,0 +1,239 @@ +{ + "AF": "Afghanistan", + "AL": "Albania", + "DZ": "Algeria", + "AS": "American Samoa", + "AD": "Andorra", + "AO": "Angola", + "AI": "Anguilla", + "AQ": "Antarctica", + "AG": "Antigua & Barbuda", + "AR": "Argentina", + "AM": "Armenia", + "AW": "Aruba", + "AU": "Australia", + "AT": "Austria", + "AZ": "Azerbaijan", + "BS": "Bahamas", + "BH": "Bahrain", + "BD": "Bangladesh", + "BB": "Barbados", + "BY": "Belarus", + "BE": "Belgium", + "BZ": "Belize", + "BJ": "Benin", + "BM": "Bermuda", + "BT": "Bhutan", + "BO": "Bolivia", + "BA": "Bosnia & Herzegovina", + "BW": "Botswana", + "BV": "Bouvet Island", + "BR": "Brazil", + "IO": "British Indian Ocean Territory", + "BN": "Brunei Darussalam", + "BG": "Bulgaria", + "BF": "Burkina Faso", + "BI": "Burundi", + "KH": "Cambodia", + "CM": "Cameroon", + "CA": "Canada", + "CV": "Cape Verde", + "KY": "Cayman Islands", + "CF": "Central African Republic", + "TD": "Chad", + "CL": "Chile", + "CN": "China", + "CX": "Christmas Island", + "CC": "Cocos (Keeling) Islands", + "CO": "Colombia", + "KM": "Comoros", + "CG": "Congo", + "CD": "Zaire", + "CK": "Cook Islands", + "CR": "Costa Rica", + "CI": "Cote D'ivoire (Ivory Coast)", + "HR": "Croatia (Hrvatska)", + "CU": "Cuba", + "CY": "Cyprus", + "CZ": "Czech Republic", + "DK": "Denmark", + "DJ": "Djibouti", + "DM": "Dominica", + "DO": "Dominican Republic", + "TP": "East Timor", + "EC": "Ecuador", + "EG": "Egypt", + "SV": "El Salvador", + "GQ": "Equatorial Guinea", + "ER": "Eritrea", + "EE": "Estonia", + "ET": "Ethiopia", + "FK": "Falkland Islands (Malvinas)", + "FO": "Faroe Islands", + "FJ": "Fiji", + "FI": "Finland", + "FR": "France", + "GF": "French Guiana", + "PF": "French Polynesia", + "TF": "French Southern Territories", + "GA": "Gabon", + "GM": "Gambia", + "GE": "Georgia", + "DE": "Germany", + "GH": "Ghana", + "GI": "Gibraltar", + "GB": "United Kingdom", + "GR": "Greece", + "GL": "Greenland", + "GD": "Grenada", + "GP": "Guadeloupe", + "GU": "Guam", + "GT": "Guatemala", + "GN": "Guinea", + "GW": "Guinea-Bissau", + "GY": "Guyana", + "HT": "Haiti", + "HM": "Heard & McDonald Islands", + "VA": "Vatican City (Holy See)", + "HN": "Honduras", + "HK": "Hong Kong", + "HU": "Hungary", + "IS": "Iceland", + "IN": "India", + "ID": "Indonesia", + "IR": "Iran", + "IQ": "Iraq", + "IE": "Ireland", + "IL": "Israel", + "IT": "Italy", + "JM": "Jamaica", + "JP": "Japan", + "JO": "Jordan", + "KZ": "Kazakhstan", + "KE": "Kenya", + "KI": "Kiribati", + "KP": "Korea (North)", + "KR": "Korea (South)", + "KW": "Kuwait", + "KG": "Kyrgyzstan", + "LA": "Laos", + "LV": "Latvia", + "LB": "Lebanon", + "LS": "Lesotho", + "LR": "Liberia", + "LY": "Libya", + "LI": "Liechtenstein", + "LT": "Lithuania", + "LU": "Luxembourg", + "MO": "Macau", + "MK": "Macedonia", + "MG": "Madagascar", + "MW": "Malawi", + "MY": "Malaysia", + "MV": "Maldives", + "ML": "Mali", + "MT": "Malta", + "MH": "Marshall Islands", + "MQ": "Martinique", + "MR": "Mauritania", + "MU": "Mauritius", + "YT": "Mayotte", + "MX": "Mexico", + "FM": "Micronesia", + "MD": "Moldova", + "MC": "Monaco", + "MN": "Mongolia", + "MS": "Montserrat", + "MA": "Morocco", + "MZ": "Mozambique", + "MM": "Myanmar", + "NA": "Namibia", + "NR": "Nauru", + "NP": "Nepal", + "NL": "Netherlands", + "AN": "Netherlands Antilles", + "NC": "New Caledonia", + "NZ": "New Zealand", + "NI": "Nicaragua", + "NE": "Niger", + "NG": "Nigeria", + "NU": "Niue", + "NF": "Norfolk Island", + "MP": "Northern Mariana Islands", + "NO": "Norway", + "OM": "Oman", + "PK": "Pakistan", + "PW": "Palau", + "PA": "Panama", + "PG": "Papua New Guinea", + "PY": "Paraguay", + "PE": "Peru", + "PH": "Philippines", + "PN": "Pitcairn", + "PL": "Poland", + "PT": "Portugal", + "PR": "Puerto Rico", + "QA": "Qatar", + "RE": "Reunion", + "RO": "Romania", + "RU": "Russian Federation", + "RW": "Rwanda", + "SH": "St. Helena", + "KN": "Saint Kitts & Nevis", + "LC": "Saint Lucia", + "PM": "St. Pierre & Miquelon", + "VC": "St. Vincent & the Grenadines", + "WS": "Samoa", + "SM": "San Marino", + "ST": "Sao Tome & Principe", + "SA": "Saudi Arabia", + "SN": "Senegal", + "SC": "Seychelles", + "SL": "Sierra Leone", + "SG": "Singapore", + "SK": "Slovak Republic", + "SI": "Slovenia", + "SB": "Solomon Islands", + "SO": "Somalia", + "ZA": "South Africa", + "GS": "S.Georgia & S.Sandwich Islands", + "ES": "Spain", + "LK": "Sri Lanka", + "SD": "Sudan", + "SR": "Suriname", + "SJ": "Svalbard & Jan Mayen Islands", + "SZ": "Swaziland", + "SE": "Sweden", + "CH": "Switzerland", + "SY": "Syria", + "TW": "Taiwan", + "TJ": "Tajikistan", + "TZ": "Tanzania", + "TH": "Thailand", + "TG": "Togo", + "TK": "Tokelau", + "TO": "Tonga", + "TT": "Trinidad & Tobago", + "TN": "Tunisia", + "TR": "Turkey", + "TM": "Turkmenistan", + "TC": "Turks & Caicos Islands", + "TV": "Tuvalu", + "UG": "Uganda", + "UA": "Ukraine", + "AE": "United Arab Emirates", + "US": "United States", + "UY": "Uruguay", + "UZ": "Uzbekistan", + "VU": "Vanuatu", + "VE": "Venezuela", + "VN": "Viet Nam", + "VG": "Virgin Islands (British)", + "VI": "Virgin Islands (U.S.)", + "WF": "Wallis & Futuna Islands", + "EH": "Western Sahara", + "YE": "Yemen", + "YU": "Yugoslavia", + "ZM": "Zambia", + "ZW": "Zimbabwe" +} diff --git a/assets/data/tmdb_language.json b/assets/data/tmdb_language.json new file mode 100644 index 0000000..ea0a8b4 --- /dev/null +++ b/assets/data/tmdb_language.json @@ -0,0 +1,937 @@ +[ + { + "iso_639_1": "xx", + "english_name": "No Language", + "name": "No Language" + }, + { + "iso_639_1": "aa", + "english_name": "Afar", + "name": "" + }, + { + "iso_639_1": "af", + "english_name": "Afrikaans", + "name": "Afrikaans" + }, + { + "iso_639_1": "ak", + "english_name": "Akan", + "name": "" + }, + { + "iso_639_1": "an", + "english_name": "Aragonese", + "name": "" + }, + { + "iso_639_1": "as", + "english_name": "Assamese", + "name": "" + }, + { + "iso_639_1": "av", + "english_name": "Avaric", + "name": "" + }, + { + "iso_639_1": "ae", + "english_name": "Avestan", + "name": "" + }, + { + "iso_639_1": "ay", + "english_name": "Aymara", + "name": "" + }, + { + "iso_639_1": "az", + "english_name": "Azerbaijani", + "name": "Azərbaycan" + }, + { + "iso_639_1": "ba", + "english_name": "Bashkir", + "name": "" + }, + { + "iso_639_1": "bm", + "english_name": "Bambara", + "name": "Bamanankan" + }, + { + "iso_639_1": "bn", + "english_name": "Bengali", + "name": "বাংলা" + }, + { + "iso_639_1": "bi", + "english_name": "Bislama", + "name": "" + }, + { + "iso_639_1": "bo", + "english_name": "Tibetan", + "name": "" + }, + { + "iso_639_1": "bs", + "english_name": "Bosnian", + "name": "Bosanski" + }, + { + "iso_639_1": "br", + "english_name": "Breton", + "name": "" + }, + { + "iso_639_1": "ca", + "english_name": "Catalan", + "name": "Català" + }, + { + "iso_639_1": "cs", + "english_name": "Czech", + "name": "Český" + }, + { + "iso_639_1": "ch", + "english_name": "Chamorro", + "name": "Finu' Chamorro" + }, + { + "iso_639_1": "ce", + "english_name": "Chechen", + "name": "" + }, + { + "iso_639_1": "cu", + "english_name": "Slavic", + "name": "" + }, + { + "iso_639_1": "cv", + "english_name": "Chuvash", + "name": "" + }, + { + "iso_639_1": "kw", + "english_name": "Cornish", + "name": "" + }, + { + "iso_639_1": "co", + "english_name": "Corsican", + "name": "" + }, + { + "iso_639_1": "cr", + "english_name": "Cree", + "name": "" + }, + { + "iso_639_1": "cy", + "english_name": "Welsh", + "name": "Cymraeg" + }, + { + "iso_639_1": "da", + "english_name": "Danish", + "name": "Dansk" + }, + { + "iso_639_1": "de", + "english_name": "German", + "name": "Deutsch" + }, + { + "iso_639_1": "dv", + "english_name": "Divehi", + "name": "" + }, + { + "iso_639_1": "dz", + "english_name": "Dzongkha", + "name": "" + }, + { + "iso_639_1": "en", + "english_name": "English", + "name": "English" + }, + { + "iso_639_1": "eo", + "english_name": "Esperanto", + "name": "Esperanto" + }, + { + "iso_639_1": "et", + "english_name": "Estonian", + "name": "Eesti" + }, + { + "iso_639_1": "eu", + "english_name": "Basque", + "name": "euskera" + }, + { + "iso_639_1": "fo", + "english_name": "Faroese", + "name": "" + }, + { + "iso_639_1": "fj", + "english_name": "Fijian", + "name": "" + }, + { + "iso_639_1": "fi", + "english_name": "Finnish", + "name": "suomi" + }, + { + "iso_639_1": "fr", + "english_name": "French", + "name": "Français" + }, + { + "iso_639_1": "fy", + "english_name": "Frisian", + "name": "" + }, + { + "iso_639_1": "ff", + "english_name": "Fulah", + "name": "Fulfulde" + }, + { + "iso_639_1": "gd", + "english_name": "Gaelic", + "name": "" + }, + { + "iso_639_1": "ga", + "english_name": "Irish", + "name": "Gaeilge" + }, + { + "iso_639_1": "gl", + "english_name": "Galician", + "name": "Galego" + }, + { + "iso_639_1": "gv", + "english_name": "Manx", + "name": "" + }, + { + "iso_639_1": "gn", + "english_name": "Guarani", + "name": "" + }, + { + "iso_639_1": "gu", + "english_name": "Gujarati", + "name": "" + }, + { + "iso_639_1": "ht", + "english_name": "Haitian; Haitian Creole", + "name": "" + }, + { + "iso_639_1": "ha", + "english_name": "Hausa", + "name": "Hausa" + }, + { + "iso_639_1": "sh", + "english_name": "Serbo-Croatian", + "name": "" + }, + { + "iso_639_1": "hz", + "english_name": "Herero", + "name": "" + }, + { + "iso_639_1": "ho", + "english_name": "Hiri Motu", + "name": "" + }, + { + "iso_639_1": "hr", + "english_name": "Croatian", + "name": "Hrvatski" + }, + { + "iso_639_1": "hu", + "english_name": "Hungarian", + "name": "Magyar" + }, + { + "iso_639_1": "ig", + "english_name": "Igbo", + "name": "" + }, + { + "iso_639_1": "io", + "english_name": "Ido", + "name": "" + }, + { + "iso_639_1": "ii", + "english_name": "Yi", + "name": "" + }, + { + "iso_639_1": "iu", + "english_name": "Inuktitut", + "name": "" + }, + { + "iso_639_1": "ie", + "english_name": "Interlingue", + "name": "" + }, + { + "iso_639_1": "ia", + "english_name": "Interlingua", + "name": "" + }, + { + "iso_639_1": "id", + "english_name": "Indonesian", + "name": "Bahasa indonesia" + }, + { + "iso_639_1": "ik", + "english_name": "Inupiaq", + "name": "" + }, + { + "iso_639_1": "is", + "english_name": "Icelandic", + "name": "Íslenska" + }, + { + "iso_639_1": "it", + "english_name": "Italian", + "name": "Italiano" + }, + { + "iso_639_1": "jv", + "english_name": "Javanese", + "name": "" + }, + { + "iso_639_1": "ja", + "english_name": "Japanese", + "name": "日本語" + }, + { + "iso_639_1": "kl", + "english_name": "Kalaallisut", + "name": "" + }, + { + "iso_639_1": "kn", + "english_name": "Kannada", + "name": "?????" + }, + { + "iso_639_1": "ks", + "english_name": "Kashmiri", + "name": "" + }, + { + "iso_639_1": "ka", + "english_name": "Georgian", + "name": "ქართული" + }, + { + "iso_639_1": "kr", + "english_name": "Kanuri", + "name": "" + }, + { + "iso_639_1": "kk", + "english_name": "Kazakh", + "name": "қазақ" + }, + { + "iso_639_1": "km", + "english_name": "Khmer", + "name": "" + }, + { + "iso_639_1": "ki", + "english_name": "Kikuyu", + "name": "" + }, + { + "iso_639_1": "rw", + "english_name": "Kinyarwanda", + "name": "Kinyarwanda" + }, + { + "iso_639_1": "ky", + "english_name": "Kirghiz", + "name": "??????" + }, + { + "iso_639_1": "kv", + "english_name": "Komi", + "name": "" + }, + { + "iso_639_1": "kg", + "english_name": "Kongo", + "name": "" + }, + { + "iso_639_1": "ko", + "english_name": "Korean", + "name": "한국어/조선말" + }, + { + "iso_639_1": "kj", + "english_name": "Kuanyama", + "name": "" + }, + { + "iso_639_1": "ku", + "english_name": "Kurdish", + "name": "" + }, + { + "iso_639_1": "lo", + "english_name": "Lao", + "name": "" + }, + { + "iso_639_1": "la", + "english_name": "Latin", + "name": "Latin" + }, + { + "iso_639_1": "lv", + "english_name": "Latvian", + "name": "Latviešu" + }, + { + "iso_639_1": "li", + "english_name": "Limburgish", + "name": "" + }, + { + "iso_639_1": "ln", + "english_name": "Lingala", + "name": "" + }, + { + "iso_639_1": "lt", + "english_name": "Lithuanian", + "name": "Lietuvių" + }, + { + "iso_639_1": "lb", + "english_name": "Letzeburgesch", + "name": "" + }, + { + "iso_639_1": "lu", + "english_name": "Luba-Katanga", + "name": "" + }, + { + "iso_639_1": "lg", + "english_name": "Ganda", + "name": "" + }, + { + "iso_639_1": "mh", + "english_name": "Marshall", + "name": "" + }, + { + "iso_639_1": "ml", + "english_name": "Malayalam", + "name": "" + }, + { + "iso_639_1": "mr", + "english_name": "Marathi", + "name": "" + }, + { + "iso_639_1": "mg", + "english_name": "Malagasy", + "name": "" + }, + { + "iso_639_1": "mt", + "english_name": "Maltese", + "name": "Malti" + }, + { + "iso_639_1": "mo", + "english_name": "Moldavian", + "name": "" + }, + { + "iso_639_1": "mn", + "english_name": "Mongolian", + "name": "" + }, + { + "iso_639_1": "mi", + "english_name": "Maori", + "name": "" + }, + { + "iso_639_1": "ms", + "english_name": "Malay", + "name": "Bahasa melayu" + }, + { + "iso_639_1": "my", + "english_name": "Burmese", + "name": "" + }, + { + "iso_639_1": "na", + "english_name": "Nauru", + "name": "" + }, + { + "iso_639_1": "nv", + "english_name": "Navajo", + "name": "" + }, + { + "iso_639_1": "nr", + "english_name": "Ndebele", + "name": "" + }, + { + "iso_639_1": "nd", + "english_name": "Ndebele", + "name": "" + }, + { + "iso_639_1": "ng", + "english_name": "Ndonga", + "name": "" + }, + { + "iso_639_1": "ne", + "english_name": "Nepali", + "name": "" + }, + { + "iso_639_1": "nl", + "english_name": "Dutch", + "name": "Nederlands" + }, + { + "iso_639_1": "nn", + "english_name": "Norwegian Nynorsk", + "name": "" + }, + { + "iso_639_1": "nb", + "english_name": "Norwegian Bokmål", + "name": "Bokmål" + }, + { + "iso_639_1": "no", + "english_name": "Norwegian", + "name": "Norsk" + }, + { + "iso_639_1": "ny", + "english_name": "Chichewa; Nyanja", + "name": "" + }, + { + "iso_639_1": "oc", + "english_name": "Occitan", + "name": "" + }, + { + "iso_639_1": "oj", + "english_name": "Ojibwa", + "name": "" + }, + { + "iso_639_1": "or", + "english_name": "Oriya", + "name": "" + }, + { + "iso_639_1": "om", + "english_name": "Oromo", + "name": "" + }, + { + "iso_639_1": "os", + "english_name": "Ossetian; Ossetic", + "name": "" + }, + { + "iso_639_1": "pa", + "english_name": "Punjabi", + "name": "ਪੰਜਾਬੀ" + }, + { + "iso_639_1": "pi", + "english_name": "Pali", + "name": "" + }, + { + "iso_639_1": "pl", + "english_name": "Polish", + "name": "Polski" + }, + { + "iso_639_1": "pt", + "english_name": "Portuguese", + "name": "Português" + }, + { + "iso_639_1": "qu", + "english_name": "Quechua", + "name": "" + }, + { + "iso_639_1": "rm", + "english_name": "Raeto-Romance", + "name": "" + }, + { + "iso_639_1": "ro", + "english_name": "Romanian", + "name": "Română" + }, + { + "iso_639_1": "rn", + "english_name": "Rundi", + "name": "Kirundi" + }, + { + "iso_639_1": "ru", + "english_name": "Russian", + "name": "Pусский" + }, + { + "iso_639_1": "sg", + "english_name": "Sango", + "name": "" + }, + { + "iso_639_1": "sa", + "english_name": "Sanskrit", + "name": "" + }, + { + "iso_639_1": "si", + "english_name": "Sinhalese", + "name": "සිංහල" + }, + { + "iso_639_1": "sk", + "english_name": "Slovak", + "name": "Slovenčina" + }, + { + "iso_639_1": "sl", + "english_name": "Slovenian", + "name": "Slovenščina" + }, + { + "iso_639_1": "se", + "english_name": "Northern Sami", + "name": "" + }, + { + "iso_639_1": "sm", + "english_name": "Samoan", + "name": "" + }, + { + "iso_639_1": "sn", + "english_name": "Shona", + "name": "" + }, + { + "iso_639_1": "sd", + "english_name": "Sindhi", + "name": "" + }, + { + "iso_639_1": "so", + "english_name": "Somali", + "name": "Somali" + }, + { + "iso_639_1": "st", + "english_name": "Sotho", + "name": "" + }, + { + "iso_639_1": "es", + "english_name": "Spanish", + "name": "Español" + }, + { + "iso_639_1": "sq", + "english_name": "Albanian", + "name": "shqip" + }, + { + "iso_639_1": "sc", + "english_name": "Sardinian", + "name": "" + }, + { + "iso_639_1": "sr", + "english_name": "Serbian", + "name": "Srpski" + }, + { + "iso_639_1": "ss", + "english_name": "Swati", + "name": "" + }, + { + "iso_639_1": "su", + "english_name": "Sundanese", + "name": "" + }, + { + "iso_639_1": "sw", + "english_name": "Swahili", + "name": "Kiswahili" + }, + { + "iso_639_1": "sv", + "english_name": "Swedish", + "name": "svenska" + }, + { + "iso_639_1": "ty", + "english_name": "Tahitian", + "name": "" + }, + { + "iso_639_1": "ta", + "english_name": "Tamil", + "name": "தமிழ்" + }, + { + "iso_639_1": "tt", + "english_name": "Tatar", + "name": "" + }, + { + "iso_639_1": "te", + "english_name": "Telugu", + "name": "తెలుగు" + }, + { + "iso_639_1": "tg", + "english_name": "Tajik", + "name": "" + }, + { + "iso_639_1": "tl", + "english_name": "Tagalog", + "name": "" + }, + { + "iso_639_1": "th", + "english_name": "Thai", + "name": "ภาษาไทย" + }, + { + "iso_639_1": "ti", + "english_name": "Tigrinya", + "name": "" + }, + { + "iso_639_1": "to", + "english_name": "Tonga", + "name": "" + }, + { + "iso_639_1": "tn", + "english_name": "Tswana", + "name": "" + }, + { + "iso_639_1": "ts", + "english_name": "Tsonga", + "name": "" + }, + { + "iso_639_1": "tk", + "english_name": "Turkmen", + "name": "" + }, + { + "iso_639_1": "tr", + "english_name": "Turkish", + "name": "Türkçe" + }, + { + "iso_639_1": "tw", + "english_name": "Twi", + "name": "" + }, + { + "iso_639_1": "ug", + "english_name": "Uighur", + "name": "" + }, + { + "iso_639_1": "uk", + "english_name": "Ukrainian", + "name": "Український" + }, + { + "iso_639_1": "ur", + "english_name": "Urdu", + "name": "اردو" + }, + { + "iso_639_1": "uz", + "english_name": "Uzbek", + "name": "ozbek" + }, + { + "iso_639_1": "ve", + "english_name": "Venda", + "name": "" + }, + { + "iso_639_1": "vi", + "english_name": "Vietnamese", + "name": "Tiếng Việt" + }, + { + "iso_639_1": "vo", + "english_name": "Volapük", + "name": "" + }, + { + "iso_639_1": "wa", + "english_name": "Walloon", + "name": "" + }, + { + "iso_639_1": "wo", + "english_name": "Wolof", + "name": "Wolof" + }, + { + "iso_639_1": "xh", + "english_name": "Xhosa", + "name": "" + }, + { + "iso_639_1": "yi", + "english_name": "Yiddish", + "name": "" + }, + { + "iso_639_1": "za", + "english_name": "Zhuang", + "name": "" + }, + { + "iso_639_1": "zu", + "english_name": "Zulu", + "name": "isiZulu" + }, + { + "iso_639_1": "ab", + "english_name": "Abkhazian", + "name": "" + }, + { + "iso_639_1": "zh", + "english_name": "Mandarin", + "name": "普通话" + }, + { + "iso_639_1": "ps", + "english_name": "Pushto", + "name": "پښتو" + }, + { + "iso_639_1": "am", + "english_name": "Amharic", + "name": "" + }, + { + "iso_639_1": "ar", + "english_name": "Arabic", + "name": "العربية" + }, + { + "iso_639_1": "be", + "english_name": "Belarusian", + "name": "беларуская мова" + }, + { + "iso_639_1": "bg", + "english_name": "Bulgarian", + "name": "български език" + }, + { + "iso_639_1": "cn", + "english_name": "Cantonese", + "name": "广州话 / 廣州話" + }, + { + "iso_639_1": "mk", + "english_name": "Macedonian", + "name": "" + }, + { + "iso_639_1": "ee", + "english_name": "Ewe", + "name": "Èʋegbe" + }, + { + "iso_639_1": "el", + "english_name": "Greek", + "name": "ελληνικά" + }, + { + "iso_639_1": "fa", + "english_name": "Persian", + "name": "فارسی" + }, + { + "iso_639_1": "he", + "english_name": "Hebrew", + "name": "עִבְרִית" + }, + { + "iso_639_1": "hi", + "english_name": "Hindi", + "name": "हिन्दी" + }, + { + "iso_639_1": "hy", + "english_name": "Armenian", + "name": "" + }, + { + "iso_639_1": "yo", + "english_name": "Yoruba", + "name": "Èdè Yorùbá" + } +] diff --git a/ios/Podfile.lock b/ios/Podfile.lock index eaa2e3c..d3e00d7 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -7,8 +7,6 @@ PODS: - connectivity_plus (0.0.1): - Flutter - FlutterMacOS - - device_info_plus (0.0.1): - - Flutter - DKImagePickerController/Core (4.3.9): - DKImagePickerController/ImageDataManager - DKImagePickerController/Resource @@ -135,9 +133,6 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS - - pdfrx (0.0.3): - - Flutter - - FlutterMacOS - permission_handler_apple (9.3.0): - Flutter - PromisesObjC (2.4.0) @@ -181,7 +176,6 @@ DEPENDENCIES: - background_downloader (from `.symlinks/plugins/background_downloader/ios`) - bonsoir_darwin (from `.symlinks/plugins/bonsoir_darwin/darwin`) - connectivity_plus (from `.symlinks/plugins/connectivity_plus/darwin`) - - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`) - Flutter (from `Flutter`) - flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/ios`) @@ -195,7 +189,6 @@ DEPENDENCIES: - media_kit_video (from `.symlinks/plugins/media_kit_video/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - - pdfrx (from `.symlinks/plugins/pdfrx/darwin`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) @@ -235,8 +228,6 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/bonsoir_darwin/darwin" connectivity_plus: :path: ".symlinks/plugins/connectivity_plus/darwin" - device_info_plus: - :path: ".symlinks/plugins/device_info_plus/ios" file_picker: :path: ".symlinks/plugins/file_picker/ios" Flutter: @@ -255,8 +246,6 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/package_info_plus/ios" path_provider_foundation: :path: ".symlinks/plugins/path_provider_foundation/darwin" - pdfrx: - :path: ".symlinks/plugins/pdfrx/darwin" permission_handler_apple: :path: ".symlinks/plugins/permission_handler_apple/ios" shared_preferences_foundation: @@ -276,7 +265,6 @@ SPEC CHECKSUMS: background_downloader: 9f788ffc5de45acf87d6380e91ca0841066c18cf bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842 connectivity_plus: 18382e7311ba19efcaee94442b23b32507b20695 - device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655 @@ -303,7 +291,6 @@ SPEC CHECKSUMS: OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94 package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4 path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 - pdfrx: 07fc287c47ea8d027c4ed56457f8a1aa74d73594 permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 SDWebImage: 73c6079366fea25fa4bb9640d5fb58f0893facd8 diff --git a/lib/app/app.dart b/lib/app/app.dart new file mode 100644 index 0000000..e15c22a --- /dev/null +++ b/lib/app/app.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:madari_client/features/pocketbase/service/pocketbase.service.dart'; +import 'package:madari_client/features/video_player/container/state/video_settings.dart'; +import 'package:provider/provider.dart'; + +import '../features/settings/service/selected_profile.dart'; +import '../features/theme/provider/theme_provider.dart'; +import 'app_router.dart'; + +class AppDefault extends StatefulWidget { + const AppDefault({ + super.key, + }); + + @override + State createState() => _AppDefaultState(); +} + +class _AppDefaultState extends State { + late GoRouter _router; + + @override + void initState() { + _router = createRouterDesktop(); + + if (AppPocketBaseService.instance.pb.authStore.isValid) { + SelectedProfileService.instance.initialize(); + } + + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, themeProvider, child) { + final theme = themeProvider.getTheme(); + + return ChangeNotifierProvider( + create: (context) => VideoSettingsProvider(), + child: MaterialApp.router( + routerConfig: _router, + title: "Madari", + theme: theme.copyWith( + textTheme: GoogleFonts.exo2TextTheme(theme.textTheme), + ), + debugShowCheckedModeBanner: false, // comes in the way of the search + ), + ); + }, + ); + } +} diff --git a/lib/app/app_router.dart b/lib/app/app_router.dart new file mode 100644 index 0000000..50e0942 --- /dev/null +++ b/lib/app/app_router.dart @@ -0,0 +1,239 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:madari_client/features/downloads/pages/downloads_page.dart'; +import 'package:madari_client/features/offline_ratings/pages/offline_ratings.dart'; +import 'package:madari_client/features/settings/pages/profile_page.dart'; + +import '../features/accounts/pages/external_account.dart'; +import '../features/auth/pages/forget_password_page.dart'; +import '../features/auth/pages/signin_page.dart'; +import '../features/auth/pages/signup_page.dart'; +import '../features/explore/pages/explore.page.dart'; +import '../features/home/pages/home_page.dart'; +import '../features/layout/widgets/scaffold_with_nav.dart'; +import '../features/library/container/create_list_widget.dart'; +import '../features/library/pages/library.page.dart'; +import '../features/library/pages/list_detail_page.dart'; +import '../features/library/types/library_types.dart'; +import '../features/pocketbase/service/pocketbase.service.dart'; +import '../features/settings/pages/appearance_page.dart'; +import '../features/settings/pages/change_password_page.dart'; +import '../features/settings/pages/debug/logs_page.dart'; +import '../features/settings/pages/full_profile_selector.dart'; +import '../features/settings/pages/layout_page.dart'; +import '../features/settings/pages/playback_settings_page.dart'; +import '../features/settings/pages/settings_page.dart'; +import '../features/settings/pages/subprofiles_page.dart'; +import '../features/streamio_addons/pages/stremio_addons_page.dart'; +import '../features/video_player/container/video_player.dart'; +import '../features/widgetter/plugins/stremio/pages/streamio_item_viewer.dart'; +import '../features/zeku/pages/integration_page.dart'; + +final GlobalKey _rootNavigatorKey = GlobalKey(); +final GlobalKey _shellNavigatorKey = + GlobalKey(); + +final GlobalKey _homeNavigatorKey = GlobalKey(); +final GlobalKey _searchNavigatorKey = + GlobalKey(); +final GlobalKey _downloadsNavigatorKey = + GlobalKey(); +final GlobalKey _settingsNavigatorKey = + GlobalKey(); +final GlobalKey _exploreNavigatorKey = + GlobalKey(); + +GoRouter createRouterDesktop() { + return GoRouter( + navigatorKey: _rootNavigatorKey, + initialLocation: '/', + refreshListenable: ValueNotifier( + AppPocketBaseService.instance.pb.authStore.onChange, + ), + redirect: (context, state) { + final isLoggedIn = AppPocketBaseService.instance.pb.authStore.isValid; + final isAuthRoute = state.uri.path == '/signin' || + state.uri.path == '/signup' || + state.uri.path == '/forgot-password'; + + if (!isLoggedIn && !isAuthRoute) { + return '/signin'; + } + + if (isLoggedIn && isAuthRoute) { + return '/'; + } + + return null; + }, + routes: [ + GoRoute( + path: '/signin', + builder: (context, state) => const SignInPage(), + ), + GoRoute( + path: '/forgot-password', + builder: (context, state) => const ForgotPasswordPage(), + ), + GoRoute( + path: '/signup', + builder: (context, state) => const SignUpPage(), + ), + GoRoute( + path: "/downloads", + builder: (context, state) => const DownloadsPage(), + ), + GoRoute( + path: "/settings/integration", + builder: (context, state) => const IntegrationPage(), + ), + StatefulShellRoute.indexedStack( + key: _shellNavigatorKey, + builder: (context, state, navigationShell) { + return ScaffoldWithNav( + child: navigationShell, + ); + }, + branches: [ + StatefulShellBranch( + navigatorKey: _homeNavigatorKey, + routes: [ + GoRoute( + path: '/', + builder: (context, state) => const HomePage(), + ), + ], + ), + StatefulShellBranch( + navigatorKey: _searchNavigatorKey, + routes: [ + GoRoute( + path: '/search', + builder: (context, state) => const HomePage( + hasSearch: true, + ), + ), + ], + ), + StatefulShellBranch( + navigatorKey: _exploreNavigatorKey, + routes: [ + GoRoute( + path: '/explore', + builder: (context, state) => const ExplorePage(), + ), + ], + ), + StatefulShellBranch( + navigatorKey: _downloadsNavigatorKey, + routes: [ + GoRoute( + path: '/library', + builder: (context, state) => const LibraryPage(), + ), + ], + ), + StatefulShellBranch( + navigatorKey: _settingsNavigatorKey, + routes: settingsRoutes, + ) + ], + ), + GoRoute( + path: "/layout", + builder: (context, state) => const LayoutPage(), + ), + GoRoute( + path: '/library/create', + builder: (context, state) => const CreateListPage(), + ), + GoRoute( + path: '/library/:id', + builder: (context, state) { + final list = state.extra as ListModel; + return ListDetailsPage(list: list); + }, + ), + GoRoute( + path: "/profile", + builder: (context, state) { + return const FullProfileSelectorPage(); + }, + ), + GoRoute( + path: "/profile/manage", + builder: (context, state) => const SubprofilesPage(), + ), + GoRoute( + path: '/meta/:type/:id', + builder: (context, state) { + return StreamioItemViewer( + id: state.pathParameters['id']!, + type: state.pathParameters['type']!, + image: state.uri.queryParameters["image"], + name: state.uri.queryParameters['name'], + prefix: state.uri.queryParameters['prefix'], + meta: state.extra is Map ? (state.extra as Map)["meta"] : null, + ); + }, + ), + GoRoute( + path: '/player/:type/:id/:stream', + builder: (context, state) => VideoPlayer( + id: state.pathParameters['id']!, + type: state.pathParameters['type']!, + stream: state.pathParameters["stream"]!, + selectedIndex: state.uri.queryParameters["index"], + meta: state.extra is Map ? (state.extra as Map)["meta"] : null, + ), + ), + GoRoute( + path: "/settings/offline-ratings", + builder: (context, state) => const OfflineRatings(), + ), + GoRoute( + path: '/settings/addons', + builder: (context, state) => const StremioAddonsPage(), + ), + ], + ); +} + +final List settingsRoutes = [ + GoRoute( + path: '/settings', + builder: (context, state) => const SettingsPage(), + ), + GoRoute( + path: '/settings/profile', + builder: (context, state) => const ProfilePage(), + ), + GoRoute( + path: '/settings/appearance', + builder: (context, state) => const AppearancePage(), + ), + GoRoute( + path: '/settings/stremio', + builder: (context, state) => const StremioAddonsPage(), + ), + GoRoute( + path: '/settings/playback', + builder: (context, state) => const PlaybackSettingsPage(), + ), + GoRoute( + path: '/settings/external-account', + builder: (context, state) => const ExternalAccount(), + ), + GoRoute( + path: '/settings/debug', + builder: (context, state) => const LogsPage(), + ), + GoRoute( + path: "/settings/security", + builder: (context, state) => const ChangePasswordPage(), + ), + GoRoute( + path: "/settings/subprofiles", + builder: (context, state) => const SubprofilesPage(), + ), +]; diff --git a/lib/features/doc_viewer/container/video_viewer/season_selector.dart b/lib/app/app_web.dart similarity index 55% rename from lib/features/doc_viewer/container/video_viewer/season_selector.dart rename to lib/app/app_web.dart index c8c60a3..bef1964 100644 --- a/lib/features/doc_viewer/container/video_viewer/season_selector.dart +++ b/lib/app/app_web.dart @@ -1,12 +1,12 @@ import 'package:flutter/material.dart'; -class SeasonSelector extends StatelessWidget { - const SeasonSelector({ +class AppWeb extends StatelessWidget { + const AppWeb({ super.key, }); @override Widget build(BuildContext context) { - return Container(); + return const MaterialApp(); } } diff --git a/lib/consts/data.dart b/lib/consts/data.dart new file mode 100644 index 0000000..4a7416f --- /dev/null +++ b/lib/consts/data.dart @@ -0,0 +1,19 @@ +const List defaultAppAddons = [ + DefaultAddon( + icon: "https://downloads.madari.media/icon.png", + title: "Madari Catalog", + url: "https://catalog.madari.media/manifest.json", + ), +]; + +class DefaultAddon { + final String title; + final String icon; + final String url; + + const DefaultAddon({ + required this.title, + required this.url, + required this.icon, + }); +} diff --git a/lib/data/db.dart b/lib/data/db.dart new file mode 100644 index 0000000..5f9fb17 --- /dev/null +++ b/lib/data/db.dart @@ -0,0 +1,55 @@ +import 'package:drift/drift.dart'; +import 'package:drift_flutter/drift_flutter.dart'; + +import 'tables/ratings.dart'; + +part 'db.g.dart'; + +@DriftDatabase(tables: [RatingTable]) +class AppDatabase extends _$AppDatabase { + AppDatabase._() : super(_openConnection()); + + static AppDatabase? _instance; + + factory AppDatabase() { + _instance ??= AppDatabase._(); + return _instance!; + } + + Future getRatingByTConst(String tconst) async { + try { + final query = select(ratingTable) + ..where((tbl) => tbl.tconst.equals(tconst)); + + final result = await query.getSingleOrNull(); + return result?.averageRating; + } catch (e) { + print('Error fetching IMDb rating: $e'); + return null; + } + } + + @override + int get schemaVersion => 1; + + static QueryExecutor _openConnection() { + return driftDatabase( + name: 'app_db', + ); + } + + @override + Future close() async { + try { + await super.close(); + _instance = null; + } catch (e) { + throw Exception('Failed to close database connection: $e'); + } + } + + static void clearInstance() { + _instance?.close(); + _instance = null; + } +} diff --git a/lib/data/tables/ratings.dart b/lib/data/tables/ratings.dart new file mode 100644 index 0000000..db94357 --- /dev/null +++ b/lib/data/tables/ratings.dart @@ -0,0 +1,10 @@ +import 'package:drift/drift.dart'; + +class RatingTable extends Table { + TextColumn get tconst => text()(); + RealColumn get averageRating => real()(); + IntColumn get numVotes => integer()(); + + @override + Set get primaryKey => {tconst}; +} diff --git a/lib/database/app_database.dart b/lib/database/app_database.dart deleted file mode 100644 index ee1e9b3..0000000 --- a/lib/database/app_database.dart +++ /dev/null @@ -1,30 +0,0 @@ -import 'package:drift/drift.dart'; -import 'package:drift_flutter/drift_flutter.dart'; -import 'package:madari_client/database/quries/watch_history_queries.dart'; -import 'package:madari_client/database/tables/watch_history_table.dart'; - -part 'app_database.g.dart'; - -@DriftDatabase(tables: [ - WatchHistoryTable, -], queries: {}, daos: [ - WatchHistoryQueries, -]) -class AppDatabase extends _$AppDatabase { - AppDatabase() : super(_openConnection()); - - @override - int get schemaVersion => 1; - - static QueryExecutor _openConnection() { - return driftDatabase( - name: 'madari_db', - web: DriftWebOptions( - sqlite3Wasm: Uri.parse('sqlite3.wasm'), - driftWorker: Uri.parse( - 'assets/assets/ignore_this_error_drift_worker.dart.js', - ), - ), - ); - } -} diff --git a/lib/database/database_provider.dart b/lib/database/database_provider.dart deleted file mode 100644 index 609389b..0000000 --- a/lib/database/database_provider.dart +++ /dev/null @@ -1,13 +0,0 @@ -import 'package:madari_client/database/app_database.dart'; - -class DatabaseProvider { - late final AppDatabase database; - - DatabaseProvider() { - database = AppDatabase(); - } - - Future close() async { - await database.close(); - } -} diff --git a/lib/database/quries/watch_history_queries.dart b/lib/database/quries/watch_history_queries.dart deleted file mode 100644 index ba44d1e..0000000 --- a/lib/database/quries/watch_history_queries.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:drift/drift.dart'; - -import '../app_database.dart'; -import '../tables/watch_history_table.dart'; - -part 'watch_history_queries.g.dart'; - -@DriftAccessor(tables: [WatchHistoryTable]) -class WatchHistoryQueries extends DatabaseAccessor - with _$WatchHistoryQueriesMixin { - WatchHistoryQueries(super.db); - - Future> getWatchHistoryByIds(List ids) { - return (select(watchHistoryTable)..where((t) => t.id.isIn(ids))).get(); - } - - Future> getUnsyncedRecords() { - return (select(watchHistoryTable) - ..where((t) => - t.lastSyncedAt.isNull() | - t.updatedAt.isBiggerThan(t.lastSyncedAt))) - .get(); - } - - Future insertOrUpdateWatchHistory(WatchHistoryTableCompanion entry) { - return into(watchHistoryTable).insertOnConflictUpdate(entry); - } - - Future updateSyncStatus(String id, DateTime syncTime) { - return (update(watchHistoryTable)..where((t) => t.id.equals(id))) - .write(WatchHistoryTableCompanion(lastSyncedAt: Value(syncTime))); - } - - Future getWatchHistoryById(String id) { - return (select(watchHistoryTable)..where((t) => t.id.equals(id))) - .getSingleOrNull(); - } - - Future clearWatchHistory() async { - await delete(watchHistoryTable).go(); - } -} diff --git a/lib/database/tables/watch_history_table.dart b/lib/database/tables/watch_history_table.dart deleted file mode 100644 index 44d8bfa..0000000 --- a/lib/database/tables/watch_history_table.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:drift/drift.dart'; - -class WatchHistoryTable extends Table { - TextColumn get id => text()(); - TextColumn get originalId => text()(); - TextColumn get season => text().nullable()(); - TextColumn get episode => text().nullable()(); - IntColumn get progress => integer().withDefault(const Constant(0))(); - RealColumn get duration => real().withDefault(const Constant(0))(); - DateTimeColumn get updatedAt => dateTime()(); - DateTimeColumn get lastSyncedAt => dateTime().nullable()(); - - @override - Set get primaryKey => {id}; -} diff --git a/lib/engine/connection.dart b/lib/engine/connection.dart deleted file mode 100644 index 9391674..0000000 --- a/lib/engine/connection.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:madari_client/features/settings/types/connection.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -import 'engine.dart'; - -part 'connection.g.dart'; - -@riverpod -Future> getConnections(Ref ref) async { - final List returnValue = []; - - final result = await AppEngine.engine.pb - .collection("connection") - .getFullList(expand: "type"); - - for (final item in result) { - if (item.id == "telegram") { - continue; - } - returnValue.add( - Connection( - id: item.id, - title: item.getStringValue("title"), - type: item.getStringValue("expand.type.type"), - config: jsonEncode(item.get("config")), - ), - ); - } - - return returnValue; -} diff --git a/lib/engine/connection_type.dart b/lib/engine/connection_type.dart deleted file mode 100644 index f44b37b..0000000 --- a/lib/engine/connection_type.dart +++ /dev/null @@ -1,67 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:madari_client/engine/engine.dart'; -import 'package:pocketbase/pocketbase.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'connection_type.g.dart'; - -@riverpod -Future> connectionTypeList(Ref ref, - {int page = 1}) async { - final result = await AppEngine.engine.pb - .collection("connection_type") - .getList(page: page, sort: "order"); - - return ResultList( - items: result.items - .map( - (item) => ConnectionTypeRecord.fromRecord(item), - ) - .toList(), - page: result.page, - perPage: result.perPage, - totalItems: result.totalItems, - totalPages: result.totalPages, - ); -} - -class ConnectionTypeRecord extends Jsonable { - final String title; - final String icon; - final String type; - final String id; - - ConnectionTypeRecord({ - required this.title, - required this.icon, - required this.type, - required this.id, - }); - - factory ConnectionTypeRecord.fromRecord(RecordModel record) => - ConnectionTypeRecord.fromJson(record.toJson()); - - factory ConnectionTypeRecord.fromJson(Map json) => - _$ConnectionTypeRecordFromJson(json); - - @override - Map toJson() => _$ConnectionTypeRecordToJson(this); -} - -ConnectionTypeRecord _$ConnectionTypeRecordFromJson( - Map json) => - ConnectionTypeRecord( - id: json['id'] as String, - title: json['title'] as String, - icon: json['icon'] as String, - type: json['type'] as String, - ); - -Map _$ConnectionTypeRecordToJson( - ConnectionTypeRecord instance) => - { - 'title': instance.title, - 'icon': instance.icon, - 'type': instance.type, - 'id': instance.id, - }; diff --git a/lib/engine/engine.dart b/lib/engine/engine.dart deleted file mode 100644 index 31a70d9..0000000 --- a/lib/engine/engine.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:http/http.dart' as http_client; -import 'package:pocketbase/pocketbase.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -import '../database/app_database.dart'; -import '../database/database_provider.dart'; - -class AppEngine { - static late AppEngine _instance; - late final DatabaseProvider _databaseProvider; - - static Future ensureInitialized() async { - final prefs = await SharedPreferences.getInstance(); - - final store = AsyncAuthStore( - save: (String data) async => prefs.setString('pb_auth', data), - initial: prefs.getString('pb_auth'), - clear: prefs.clear, - ); - - _instance = AppEngine(store); - } - - static AppEngine get engine => _instance; - - late final PocketBase pb; - late final http_client.Client http; - - AppDatabase get database => _databaseProvider.database; - - Future dispose() async { - await _databaseProvider.close(); - } - - AppEngine(AuthStore authStore) { - pb = PocketBase( - (kDebugMode ? 'http://100.64.0.1:8090' : 'https://api.madari.media'), - authStore: authStore, - ); - _databaseProvider = DatabaseProvider(); - http = pb.httpClientFactory(); - } - - Future signIn(String username, String password) { - return pb.collection('users').authWithPassword(username, password); - } -} diff --git a/lib/engine/library.dart b/lib/engine/library.dart deleted file mode 100644 index 0d7a855..0000000 --- a/lib/engine/library.dart +++ /dev/null @@ -1,139 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:json_annotation/json_annotation.dart'; -import 'package:madari_client/engine/engine.dart'; -import 'package:madari_client/features/watch_history/service/base_watch_history.dart'; -import 'package:pocketbase/pocketbase.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -import '../features/connections/types/base/base.dart'; -import '../features/watch_history/service/zeee_watch_history.dart'; - -export '../features/connections/types/base/base.dart'; - -part 'library.g.dart'; - -final Map> _libraryListCache = {}; - -@riverpod -Future> libraryList(Ref ref, int page) async { - if (_libraryListCache.containsKey(page.toString())) { - return _libraryListCache[page.toString()]!; - } - - final result = await AppEngine.engine.pb.collection("library").getList( - page: page, - sort: "+order", - ); - - final returnValue = ResultList(); - - returnValue.totalItems = result.totalItems; - returnValue.perPage = result.perPage; - returnValue.page = result.page; - returnValue.totalPages = result.totalPages; - returnValue.items = result.items.where((item) { - final connectionType = item.getStringValue("connectionType"); - - if (connectionType != "telegram") { - return true; - } - - return false; - }).map((item) { - final i = item; - return LibraryRecord.fromJson({ - "id": i.id, - "connectionType": i.getStringValue("connectionType"), - "icon": i.getStringValue("icon"), - "title": i.getStringValue("title"), - "types": i.getListValue("types"), - "config": i.getStringValue("config"), - "connection": i.getStringValue("connection"), - }); - }).toList(); - - _libraryListCache[page.toString()] = returnValue; - - return returnValue; -} - -final Map> _cache = {}; - -@riverpod -Future> libraryItemList( - Ref ref, - LibraryRecord library, - List? item, - int page, - String? search, -) async { - final cache = "${library.id}_${page}_$search"; - - final history = ZeeeWatchHistoryStatic.service; - - final result = _cache[cache]!.items.map((item) { - return WatchHistoryGetRequest( - id: item.id, - ); - }).toList(); - - final watchHistory = await history!.getItemWatchHistory(ids: result); - - _cache[cache]!.items = _cache[cache]!.items.map((item) { - final history = watchHistory.where((history) => history.id == item.id); - - item.history = history.isEmpty ? null : history.first; - return item; - }).toList(); - - return _cache[cache]!; -} - -@JsonSerializable() -class LibraryItemList extends Jsonable { - final String title; - final String? logo; - final int? size; - final String? extra; - final dynamic id; - final String? config; - final DateTime? date; - final double? popularity; - WatchHistory? history; - - LibraryItemList({ - required this.id, - required this.title, - this.config, - this.logo, - this.size, - this.date, - this.extra, - this.popularity = 0, - this.history, - }); - - factory LibraryItemList.fromRecord(RecordModel record) => - LibraryItemList.fromJson(record.toJson()); - - factory LibraryItemList.fromJson(Map json) => - _$LibraryItemListFromJson(json); - - @override - Map toJson() => _$LibraryItemListToJson(this); -} - -class FolderItem { - final String title; - final String id; - final Widget? icon; - final String? config; - - FolderItem({ - required this.title, - required this.id, - this.icon, - this.config, - }); -} diff --git a/lib/extension/image_to_bytes.dart b/lib/extension/image_to_bytes.dart deleted file mode 100644 index 910467b..0000000 --- a/lib/extension/image_to_bytes.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'dart:io'; -import 'dart:ui' as ui; - -import 'package:path_provider/path_provider.dart'; - -extension UIImageToInputImage on ui.Image { - Future toFile({String? fileName}) async { - // Convert image to byte data - final byteData = await toByteData(format: ui.ImageByteFormat.png); - - if (byteData == null) { - throw Exception('Failed to convert ui.Image to ByteData'); - } - - // Get the application temporary directory - final directory = await getTemporaryDirectory(); - - // Create a file with a unique name if not provided - final file = File( - '${directory.path}/${fileName ?? 'image_${DateTime.now().millisecondsSinceEpoch}.png'}'); - - // Write bytes to file - await file.writeAsBytes(byteData.buffer.asUint8List()); - - return file; - } -} diff --git a/lib/features/accounts/container/trakt.container.dart b/lib/features/accounts/container/trakt.container.dart new file mode 100644 index 0000000..2ef61a8 --- /dev/null +++ b/lib/features/accounts/container/trakt.container.dart @@ -0,0 +1,184 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../common/utils/refresh_auth.dart'; +import '../../pocketbase/service/pocketbase.service.dart'; + +class TraktContainer extends StatefulWidget { + const TraktContainer({super.key}); + + @override + State createState() => _TraktContainerState(); +} + +class _TraktContainerState extends State { + final pb = AppPocketBaseService.instance.pb; + bool isLoggedIn = false; + bool isLoading = false; + + @override + void initState() { + super.initState(); + checkIsLoggedIn(); + } + + void checkIsLoggedIn() { + final traktToken = pb.authStore.record!.getStringValue("trakt_token"); + setState(() { + isLoggedIn = traktToken != ""; + }); + } + + Future removeAccount() async { + setState(() => isLoading = true); + try { + final record = pb.authStore.record!; + record.set("trakt_token", ""); + + await pb.collection('users').update( + record.id, + body: record.toJson(), + ); + + await refreshAuth(); + setState(() { + isLoggedIn = false; + }); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(e.toString()), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } finally { + if (mounted) setState(() => isLoading = false); + } + } + + Future loginWithTrakt() async { + setState(() => isLoading = true); + try { + await pb.collection("users").authWithOAuth2( + "oidc", + (url) async { + final newUrl = Uri.parse( + url.toString().replaceFirst( + "scope=openid&", + "", + ), + ); + await launchUrl(newUrl); + }, + scopes: ["openid"], + ); + + await refreshAuth(); + checkIsLoggedIn(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(e.toString()), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } finally { + if (mounted) setState(() => isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final screenWidth = MediaQuery.of(context).size.width; + + final isDesktopOrTV = screenWidth > 1024; + final isTablet = screenWidth > 600 && screenWidth <= 1024; + final horizontalPadding = isDesktopOrTV + ? screenWidth * 0.2 + : isTablet + ? 48.0 + : 24.0; + + return Padding( + padding: EdgeInsets.symmetric( + horizontal: horizontalPadding, + vertical: 24, + ), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + isLoggedIn ? 'Connected to Trakt' : 'Connect with Trakt', + style: theme.textTheme.displaySmall?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + isLoggedIn + ? 'Your Trakt account is connected' + : 'Sign in to track your movies and shows', + style: theme.textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.7), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + SizedBox( + height: 50, + child: FilledButton( + onPressed: isLoading + ? null + : (isLoggedIn ? removeAccount : loginWithTrakt), + style: FilledButton.styleFrom( + backgroundColor: + isLoggedIn ? colorScheme.error : colorScheme.primary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ).copyWith( + overlayColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.hovered)) { + return colorScheme.onPrimary.withValues(alpha: 0.08); + } + if (states.contains(WidgetState.pressed)) { + return colorScheme.onPrimary.withValues(alpha: 0.12); + } + return null; + }), + ), + child: isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: + AlwaysStoppedAnimation(Colors.white), + ), + ) + : Text( + isLoggedIn + ? 'Disconnect Account' + : 'Connect with Trakt', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/accounts/pages/external_account.dart b/lib/features/accounts/pages/external_account.dart new file mode 100644 index 0000000..7f60e00 --- /dev/null +++ b/lib/features/accounts/pages/external_account.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:madari_client/features/accounts/container/trakt.container.dart'; + +import '../../settings/widget/setting_wrapper.dart'; + +class ExternalAccount extends StatelessWidget { + const ExternalAccount({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text("External Accounts"), + ), + body: SettingWrapper( + child: ListView( + children: [ + _buildSection( + "Trakt", + [ + const TraktContainer(), + ], + ), + ], + ), + ), + ); + } + + Widget _buildSection(String title, List children) { + return Card( + margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 0), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + ...children, + ], + ), + ), + ); + } +} diff --git a/lib/features/auth/pages/forget_password_page.dart b/lib/features/auth/pages/forget_password_page.dart new file mode 100644 index 0000000..ab80d2d --- /dev/null +++ b/lib/features/auth/pages/forget_password_page.dart @@ -0,0 +1,205 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../../pocketbase/service/pocketbase.service.dart'; + +class ForgotPasswordPage extends StatefulWidget { + const ForgotPasswordPage({super.key}); + + @override + State createState() => _ForgotPasswordPageState(); +} + +class _ForgotPasswordPageState extends State { + final _formKey = GlobalKey(); + final _emailController = TextEditingController(); + final _emailFocusNode = FocusNode(); + bool _isLoading = false; + final pocketbase = AppPocketBaseService.instance.pb; + + Widget _buildEmailField() { + return TextFormField( + controller: _emailController, + focusNode: _emailFocusNode, + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.done, + autofillHints: const [AutofillHints.email], + style: Theme.of(context).textTheme.bodyLarge, + decoration: InputDecoration( + labelText: 'Email', + prefixIcon: const Icon(Icons.email_outlined), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.primary, + width: 2, + ), + ), + filled: true, + fillColor: Theme.of(context).colorScheme.surface, + ), + validator: (value) { + if (value?.isEmpty ?? true) { + return 'Please enter your email'; + } + if (!RegExp(r'^[\w-.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value!)) { + return 'Please enter a valid email'; + } + return null; + }, + onFieldSubmitted: (_) => _requestPasswordReset(), + ); + } + + Future _requestPasswordReset() async { + if (!_formKey.currentState!.validate()) return; + + setState(() => _isLoading = true); + + try { + await pocketbase + .collection('users') + .requestPasswordReset(_emailController.text.trim()); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Password reset instructions have been sent to your email'), + backgroundColor: Colors.green, + ), + ); + // Navigate back to sign in page + context.go('/signin'); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to send reset instructions: ${e.toString()}'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Scaffold( + backgroundColor: colorScheme.surface, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + leading: IconButton( + icon: Icon(Icons.arrow_back, color: colorScheme.onSurface), + onPressed: () => context.go('/signin'), + ), + ), + body: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: Form( + key: _formKey, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Hero( + tag: 'app_logo', + child: Image.asset( + 'assets/icon/icon_mini.png', + height: 80, + width: 80, + ), + ), + const SizedBox(height: 24), + Text( + 'Forgot Password', + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Enter your email address to receive password reset instructions', + style: theme.textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.7), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + _buildEmailField(), + const SizedBox(height: 24), + AnimatedContainer( + duration: const Duration(milliseconds: 300), + height: 50, + child: ElevatedButton( + onPressed: _isLoading ? null : _requestPasswordReset, + style: ElevatedButton.styleFrom( + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: _isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Colors.white, + ), + ), + ) + : const Text( + 'Reset Password', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } + + @override + void dispose() { + _emailController.dispose(); + _emailFocusNode.dispose(); + super.dispose(); + } +} diff --git a/lib/features/auth/pages/signin_page.dart b/lib/features/auth/pages/signin_page.dart new file mode 100644 index 0000000..ea7a1d0 --- /dev/null +++ b/lib/features/auth/pages/signin_page.dart @@ -0,0 +1,517 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:go_router/go_router.dart'; +import 'package:pocketbase/pocketbase.dart'; + +import '../../common/utils/error_handler.dart'; +import '../../pocketbase/service/pocketbase.service.dart'; +import '../../theme/theme/app_theme.dart'; + +class SignInPage extends StatefulWidget { + const SignInPage({super.key}); + + @override + State createState() => _SignInPageState(); +} + +class _SignInPageState extends State + with SingleTickerProviderStateMixin { + final _formKey = GlobalKey(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + final _emailFocusNode = FocusNode(); + final _passwordFocusNode = FocusNode(); + final _signInButtonFocusNode = FocusNode(); + final _forgotPasswordFocusNode = FocusNode(); + final _signUpButtonFocusNode = FocusNode(); + final _themeToggleFocusNode = FocusNode(); + late AnimationController _animationController; + late Animation _fadeAnimation; + late Animation _slideAnimation; + bool _isLoading = false; + bool _obscurePassword = true; + final pocketbase = AppPocketBaseService.instance.pb; + + @override + void initState() { + super.initState(); + _setupAnimations(); + _animationController.forward(); + + // Set initial focus to email field + WidgetsBinding.instance.addPostFrameCallback((_) { + FocusScope.of(context).requestFocus(_emailFocusNode); + }); + } + + void _setupAnimations() { + _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 800), + ); + + _fadeAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: _animationController, + curve: Curves.easeOut, + )); + + _slideAnimation = Tween( + begin: const Offset(0, 0.1), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: _animationController, + curve: Curves.easeOut, + )); + } + + Widget _buildAnimatedTextField({ + required TextEditingController controller, + required FocusNode focusNode, + required String label, + required IconData icon, + required String? Function(String?) validator, + bool isPassword = false, + required TextInputType keyboardType, + required TextInputAction textInputAction, + required List autofillHints, + required VoidCallback? onEditingComplete, + }) { + return TweenAnimationBuilder( + tween: Tween(begin: 0.0, end: 1.0), + duration: const Duration(milliseconds: 500), + builder: (context, value, child) { + return Transform.translate( + offset: Offset(0, 20 * (1 - value)), + child: Opacity( + opacity: value, + child: child, + ), + ); + }, + child: RawKeyboardListener( + focusNode: FocusNode(), + onKey: (RawKeyEvent event) { + if (event is RawKeyDownEvent) { + if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + if (focusNode == _emailFocusNode) { + FocusScope.of(context).requestFocus(_passwordFocusNode); + } else if (focusNode == _passwordFocusNode) { + FocusScope.of(context).requestFocus(_forgotPasswordFocusNode); + } + } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) { + if (focusNode == _passwordFocusNode) { + FocusScope.of(context).requestFocus(_emailFocusNode); + } else if (focusNode == _forgotPasswordFocusNode) { + FocusScope.of(context).requestFocus(_passwordFocusNode); + } + } + } + }, + child: TextFormField( + controller: controller, + focusNode: focusNode, + obscureText: isPassword ? _obscurePassword : false, + keyboardType: keyboardType, + textInputAction: textInputAction, + autofillHints: autofillHints, + onEditingComplete: onEditingComplete, + style: Theme.of(context).textTheme.bodyLarge, + decoration: InputDecoration( + labelText: label, + prefixIcon: Icon(icon), + suffixIcon: isPassword + ? IconButton( + focusNode: FocusNode(), + icon: Icon( + _obscurePassword + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + ), + onPressed: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + tooltip: + _obscurePassword ? 'Show password' : 'Hide password', + ) + : null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.primary, + width: 2, + ), + ), + filled: true, + fillColor: Theme.of(context).colorScheme.surface, + ), + validator: validator, + ), + ), + ); + } + + Widget _buildThemeToggle() { + final isDark = Theme.of(context).brightness == Brightness.dark; + return Focus( + focusNode: _themeToggleFocusNode, + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: _themeToggleFocusNode.hasFocus + ? Theme.of(context).colorScheme.primary + : Colors.transparent, + width: 2, + ), + ), + child: IconButton( + focusNode: FocusNode(), + icon: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + transitionBuilder: (Widget child, Animation animation) { + return RotationTransition( + turns: animation, + child: FadeTransition( + opacity: animation, + child: child, + ), + ); + }, + child: Icon( + isDark ? Icons.light_mode : Icons.dark_mode, + key: ValueKey(isDark), + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + onPressed: () { + AppTheme().toggleTheme(); + }, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Scaffold( + backgroundColor: colorScheme.surface, + body: RawKeyboardListener( + focusNode: FocusNode(), + onKey: (RawKeyEvent event) { + if (event is RawKeyDownEvent) { + if (event.logicalKey == LogicalKeyboardKey.select) { + // Handle select button press based on current focus + if (_signInButtonFocusNode.hasFocus) { + _signIn(); + } + } + } + }, + child: Stack( + children: [ + Positioned( + top: 16, + right: 16, + child: _buildThemeToggle(), + ), + Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: FadeTransition( + opacity: _fadeAnimation, + child: SlideTransition( + position: _slideAnimation, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: AutofillGroup( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Hero( + tag: 'app_logo', + child: Image.asset( + 'assets/icon/icon_mini.png', + height: 80, + width: 80, + ), + ), + const SizedBox(height: 24), + Text( + 'Madari', + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Welcome back', + style: theme.textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurface.withValues( + alpha: 0.7, + ), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildAnimatedTextField( + controller: _emailController, + focusNode: _emailFocusNode, + label: 'Email', + icon: Icons.email_outlined, + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.next, + autofillHints: const [AutofillHints.email], + onEditingComplete: () { + FocusScope.of(context) + .requestFocus(_passwordFocusNode); + }, + validator: (value) { + if (value?.isEmpty ?? true) { + return 'Please enter your email'; + } + if (!RegExp( + r'^[\w-.]+@([\w-]+\.)+[\w-]{2,4}$') + .hasMatch(value!)) { + return 'Please enter a valid email'; + } + return null; + }, + ), + const SizedBox(height: 16), + _buildAnimatedTextField( + controller: _passwordController, + focusNode: _passwordFocusNode, + label: 'Password', + icon: Icons.lock_outline, + isPassword: true, + keyboardType: TextInputType.visiblePassword, + textInputAction: TextInputAction.done, + autofillHints: const [ + AutofillHints.password + ], + onEditingComplete: () { + FocusScope.of(context) + .requestFocus(_signInButtonFocusNode); + }, + validator: (value) { + if (value?.isEmpty ?? true) { + return 'Please enter your password'; + } + if (value!.length < 6) { + return 'Password must be at least 6 characters'; + } + return null; + }, + ), + Align( + alignment: Alignment.centerRight, + child: Focus( + focusNode: _forgotPasswordFocusNode, + child: TextButton( + onPressed: () { + context.go('/forgot-password'); + }, + style: TextButton.styleFrom( + foregroundColor: colorScheme.primary, + ), + child: const Text('Forgot Password?'), + ), + ), + ), + const SizedBox(height: 24), + Focus( + focusNode: _signInButtonFocusNode, + child: AnimatedContainer( + duration: + const Duration(milliseconds: 300), + height: 50, + decoration: BoxDecoration( + border: Border.all( + color: _signInButtonFocusNode.hasFocus + ? colorScheme.primary + : Colors.transparent, + width: 2, + ), + borderRadius: BorderRadius.circular(14), + ), + child: ElevatedButton( + onPressed: _isLoading ? null : _signIn, + style: ElevatedButton.styleFrom( + backgroundColor: colorScheme.primary, + foregroundColor: + colorScheme.onPrimary, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(12), + ), + ), + child: _isLoading + ? const SizedBox( + height: 20, + width: 20, + child: + CircularProgressIndicator( + strokeWidth: 2, + valueColor: + AlwaysStoppedAnimation< + Color>( + Colors.white, + ), + ), + ) + : const Text( + 'Sign In', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "Don't have an account? ", + style: TextStyle( + color: colorScheme.onSurface + .withValues(alpha: 0.7), + ), + ), + Focus( + focusNode: _signUpButtonFocusNode, + child: TextButton( + onPressed: () { + context.go('/signup'); + }, + style: TextButton.styleFrom( + foregroundColor: + colorScheme.primary, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + ), + child: Container( + decoration: BoxDecoration( + border: Border.all( + color: _signUpButtonFocusNode + .hasFocus + ? colorScheme.primary + : Colors.transparent, + width: 2, + ), + borderRadius: + BorderRadius.circular(8), + ), + padding: const EdgeInsets.all(4), + child: const Text( + 'Sign Up', + style: TextStyle( + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ], + ), + ), + ); + } + + Future _signIn() async { + if (!_formKey.currentState!.validate()) return; + + setState(() => _isLoading = true); + + try { + await pocketbase.collection('users').authWithPassword( + _emailController.text.trim(), + _passwordController.text, + ); + + if (mounted) { + context.go('/profile'); + } + } on ClientException catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(getErrorMessage(e)), + backgroundColor: Colors.red, + ), + ); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + _emailFocusNode.dispose(); + _passwordFocusNode.dispose(); + _signInButtonFocusNode.dispose(); + _forgotPasswordFocusNode.dispose(); + _signUpButtonFocusNode.dispose(); + _themeToggleFocusNode.dispose(); + _animationController.dispose(); + super.dispose(); + } +} diff --git a/lib/features/auth/pages/signup_page.dart b/lib/features/auth/pages/signup_page.dart new file mode 100644 index 0000000..4c7d665 --- /dev/null +++ b/lib/features/auth/pages/signup_page.dart @@ -0,0 +1,538 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:logging/logging.dart'; +import 'package:madari_client/consts/data.dart'; +import 'package:madari_client/features/settings/service/selected_profile.dart'; +import 'package:madari_client/features/streamio_addons/extension/query_extension.dart'; +import 'package:madari_client/features/streamio_addons/service/stremio_addon_service.dart'; +import 'package:pocketbase/pocketbase.dart'; + +import '../../common/utils/error_handler.dart'; +import '../../pocketbase/service/pocketbase.service.dart'; +import '../../theme/theme/app_theme.dart'; +import '../service/layout_service.dart'; + +class SignUpPage extends StatefulWidget { + const SignUpPage({super.key}); + + @override + State createState() => _SignUpPageState(); +} + +class _SignUpPageState extends State + with SingleTickerProviderStateMixin { + final _logger = Logger("SignUpPage"); + final _formKey = GlobalKey(); + final _nameController = TextEditingController(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + final _confirmPasswordController = TextEditingController(); + final _nameFocusNode = FocusNode(); + final _emailFocusNode = FocusNode(); + final _passwordFocusNode = FocusNode(); + final _confirmPasswordFocusNode = FocusNode(); + late AnimationController _animationController; + late Animation _fadeAnimation; + late Animation _slideAnimation; + bool _isLoading = false; + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + final pocketbase = AppPocketBaseService.instance.pb; + + void _setupKeyboardListeners() { + _nameFocusNode.addListener(() { + if (!_nameFocusNode.hasFocus && _nameController.text.isNotEmpty) { + _formKey.currentState?.validate(); + } + }); + + _emailFocusNode.addListener(() { + if (!_emailFocusNode.hasFocus && _emailController.text.isNotEmpty) { + _formKey.currentState?.validate(); + } + }); + + _passwordFocusNode.addListener(() { + if (!_passwordFocusNode.hasFocus && _passwordController.text.isNotEmpty) { + _formKey.currentState?.validate(); + } + }); + + _confirmPasswordFocusNode.addListener(() { + if (!_confirmPasswordFocusNode.hasFocus && + _confirmPasswordController.text.isNotEmpty) { + _formKey.currentState?.validate(); + } + }); + } + + @override + void initState() { + super.initState(); + _setupAnimations(); + _setupKeyboardListeners(); + _animationController.forward(); + } + + void _setupAnimations() { + _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 800), + ); + + _fadeAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: _animationController, + curve: Curves.easeOut, + )); + + _slideAnimation = Tween( + begin: const Offset(0, 0.1), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: _animationController, + curve: Curves.easeOut, + )); + } + + Widget _buildAnimatedTextField({ + required TextEditingController controller, + required FocusNode focusNode, + required String label, + required IconData icon, + required String? Function(String?) validator, + bool isPassword = false, + required TextInputType keyboardType, + required TextInputAction textInputAction, + required List autofillHints, + required VoidCallback? onEditingComplete, + bool isConfirmPassword = false, + }) { + return TweenAnimationBuilder( + tween: Tween(begin: 0.0, end: 1.0), + duration: const Duration(milliseconds: 500), + builder: (context, value, child) { + return Transform.translate( + offset: Offset(0, 20 * (1 - value)), + child: Opacity( + opacity: value, + child: child, + ), + ); + }, + child: TextFormField( + controller: controller, + focusNode: focusNode, + obscureText: isPassword + ? (isConfirmPassword ? _obscureConfirmPassword : _obscurePassword) + : false, + keyboardType: keyboardType, + textInputAction: textInputAction, + autofillHints: autofillHints, + onEditingComplete: onEditingComplete, + style: Theme.of(context).textTheme.bodyLarge, + decoration: InputDecoration( + labelText: label, + prefixIcon: Icon(icon), + suffixIcon: isPassword + ? IconButton( + icon: Icon( + (isConfirmPassword + ? _obscureConfirmPassword + : _obscurePassword) + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + ), + onPressed: () { + setState(() { + if (isConfirmPassword) { + _obscureConfirmPassword = !_obscureConfirmPassword; + } else { + _obscurePassword = !_obscurePassword; + } + }); + }, + tooltip: (isConfirmPassword + ? _obscureConfirmPassword + : _obscurePassword) + ? 'Show password' + : 'Hide password', + ) + : null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: + Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.primary, + width: 2, + ), + ), + filled: true, + fillColor: Theme.of(context).colorScheme.surface, + ), + validator: validator, + ), + ); + } + + Widget _buildThemeToggle() { + final isDark = Theme.of(context).brightness == Brightness.dark; + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(24), + ), + child: IconButton( + icon: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + transitionBuilder: (Widget child, Animation animation) { + return RotationTransition( + turns: animation, + child: FadeTransition( + opacity: animation, + child: child, + ), + ); + }, + child: Icon( + isDark ? Icons.light_mode : Icons.dark_mode, + key: ValueKey(isDark), + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + onPressed: () { + AppTheme().toggleTheme(); + }, + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Scaffold( + backgroundColor: colorScheme.surface, + body: Stack( + children: [ + Positioned( + top: 16, + right: 16, + child: _buildThemeToggle(), + ), + Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: FadeTransition( + opacity: _fadeAnimation, + child: SlideTransition( + position: _slideAnimation, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: AutofillGroup( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Hero( + tag: 'app_logo', + child: Image.asset( + 'assets/icon/icon_mini.png', + height: 80, + width: 80, + ), + ), + const SizedBox(height: 24), + Text( + 'Madari', + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Create your account', + style: theme.textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurface.withValues( + alpha: 0.7, + ), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildAnimatedTextField( + controller: _nameController, + focusNode: _nameFocusNode, + label: 'Name', + icon: Icons.person_outline, + keyboardType: TextInputType.name, + textInputAction: TextInputAction.next, + autofillHints: const [AutofillHints.name], + onEditingComplete: () { + _emailFocusNode.requestFocus(); + }, + validator: (value) { + if (value?.isEmpty ?? true) { + return 'Please enter your name'; + } + return null; + }, + ), + const SizedBox(height: 16), + _buildAnimatedTextField( + controller: _emailController, + focusNode: _emailFocusNode, + label: 'Email', + icon: Icons.email_outlined, + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.next, + autofillHints: const [AutofillHints.email], + onEditingComplete: () { + _passwordFocusNode.requestFocus(); + }, + validator: (value) { + if (value?.isEmpty ?? true) { + return 'Please enter your email'; + } + if (!RegExp( + r'^[\w-.]+@([\w-]+\.)+[\w-]{2,4}$') + .hasMatch(value!)) { + return 'Please enter a valid email'; + } + return null; + }, + ), + const SizedBox(height: 16), + _buildAnimatedTextField( + controller: _passwordController, + focusNode: _passwordFocusNode, + label: 'Password', + icon: Icons.lock_outline, + isPassword: true, + keyboardType: TextInputType.visiblePassword, + textInputAction: TextInputAction.next, + autofillHints: const [ + AutofillHints.newPassword + ], + onEditingComplete: () { + _confirmPasswordFocusNode.requestFocus(); + }, + validator: (value) { + if (value?.isEmpty ?? true) { + return 'Please enter your password'; + } + if (value!.length < 6) { + return 'Password must be at least 6 characters'; + } + return null; + }, + ), + const SizedBox(height: 16), + _buildAnimatedTextField( + controller: _confirmPasswordController, + focusNode: _confirmPasswordFocusNode, + label: 'Confirm Password', + icon: Icons.lock_outline, + isPassword: true, + isConfirmPassword: true, + keyboardType: TextInputType.visiblePassword, + textInputAction: TextInputAction.done, + autofillHints: const [ + AutofillHints.newPassword + ], + onEditingComplete: _signUp, + validator: (value) { + if (value?.isEmpty ?? true) { + return 'Please confirm your password'; + } + if (value != _passwordController.text) { + return 'Passwords do not match'; + } + return null; + }, + ), + const SizedBox(height: 24), + AnimatedContainer( + duration: const Duration(milliseconds: 300), + height: 50, + child: ElevatedButton( + onPressed: _isLoading ? null : _signUp, + style: ElevatedButton.styleFrom( + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: _isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: + AlwaysStoppedAnimation( + Colors.white, + ), + ), + ) + : const Text( + 'Sign Up', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Already have an account? ', + style: TextStyle( + color: colorScheme.onSurface + .withValues(alpha: 0.7), + ), + ), + TextButton( + onPressed: () { + context.go('/signin'); + }, + style: TextButton.styleFrom( + foregroundColor: colorScheme.primary, + ), + child: const Text( + 'Sign In', + style: TextStyle( + fontWeight: FontWeight.w600), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ], + ), + ); + } + + Future _signUp() async { + if (!_formKey.currentState!.validate()) return; + + setState(() => _isLoading = true); + + try { + final userData = { + "email": _emailController.text.trim(), + "password": _passwordController.text, + "passwordConfirm": _confirmPasswordController.text, + "name": _nameController.text.trim(), + }; + + final user = await pocketbase.collection('users').create(body: userData); + + await pocketbase.collection('users').authWithPassword( + _emailController.text.trim(), + _passwordController.text, + ); + + final profile = await pocketbase.collection('account_profile').create( + body: { + "name": _nameController.text.trim(), + "can_search": true, + 'user': pocketbase.authStore.record!.id, + }, + ); + + await SelectedProfileService.instance.setSelectedProfile(profile.id); + + for (final defaultAddon in defaultAppAddons) { + final manifest = await StremioAddonService.instance + .validateManifest(defaultAddon.url, noCache: true) + .queryFn(); + await StremioAddonService.instance.saveAddon(manifest); + } + + await LayoutService.instance.addAllHomeWidgets(); + + if (mounted) { + context.go('/profile'); + } + } on ClientException catch (e, stack) { + _logger.warning("Unable to sign up", e, stack); + if (mounted) { + String errorMessage = getErrorMessage(e); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(errorMessage), + backgroundColor: Theme.of(context).colorScheme.error, + behavior: SnackBarBehavior.floating, + action: SnackBarAction( + label: 'OK', + textColor: Theme.of(context).colorScheme.onError, + onPressed: () { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + }, + ), + ), + ); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + @override + void dispose() { + _nameController.dispose(); + _emailController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + _nameFocusNode.dispose(); + _emailFocusNode.dispose(); + _passwordFocusNode.dispose(); + _confirmPasswordFocusNode.dispose(); + _animationController.dispose(); + super.dispose(); + } +} diff --git a/lib/features/auth/service/layout_service.dart b/lib/features/auth/service/layout_service.dart new file mode 100644 index 0000000..34fc331 --- /dev/null +++ b/lib/features/auth/service/layout_service.dart @@ -0,0 +1,56 @@ +import 'package:logging/logging.dart'; + +import '../../pocketbase/service/pocketbase.service.dart'; +import '../../widgetter/plugin_base.dart'; +import '../../widgetter/service/home_layout_service.dart'; + +class LayoutService { + static final LayoutService _instance = LayoutService._internal(); + static LayoutService get instance => _instance; + + final _logger = Logger('LayoutService'); + + LayoutService._internal(); + + Future<(int, String?)> addAllHomeWidgets() async { + try { + _logger.info('Adding all widgets for new account'); + + final userId = AppPocketBaseService.instance.pb.authStore.record!.id; + int successCount = 0; + + final result = PluginRegistry.instance.getAvailablePlugins(); + final presets = await Future.wait( + result.map((item) => item.presets()), + ); + + final allWidgets = presets.expand((element) => element).toList(); + + for (final preset in allWidgets) { + final newWidget = LayoutWidgetConfig.fromPreset( + preset, + userId, + successCount, + ); + + final success = await HomeLayoutService.instance.saveLayoutWidget( + newWidget, + ); + + if (success) { + successCount++; + } + } + + if (successCount > 0) { + _logger.info('Successfully added $successCount widgets'); + return (successCount, null); + } else { + return (0, 'Failed to add widgets. Please try again.'); + } + } catch (e) { + _logger.severe('Error adding all widgets', e); + return (0, 'Failed to add widgets. Please check your connection.'); + } + } +} diff --git a/lib/features/chat/container/chat_action.dart b/lib/features/chat/container/chat_action.dart deleted file mode 100644 index 027c0d2..0000000 --- a/lib/features/chat/container/chat_action.dart +++ /dev/null @@ -1,155 +0,0 @@ -import 'package:file_picker/file_picker.dart'; -import 'package:flutter/material.dart'; -import 'package:madari_client/engine/engine.dart'; -import 'package:madari_client/utils/ocr_file.dart'; - -class ChatAction extends StatefulWidget { - final void Function({ - String? actionId, - Map? files, - }) onClose; - final String? actionId; - - const ChatAction({ - super.key, - required this.onClose, - this.actionId, - }); - - @override - State createState() => _ChatActionState(); -} - -class _ChatActionState extends State { - final List> _commandItems = [ - { - 'id': 'file-upload', - 'title': 'Upload File', - 'description': 'Share a document or image', - }, - ]; - String? content; - - bool _isLoading = true; - - @override - void initState() { - super.initState(); - - AppEngine.engine.pb - .collection("ai_action") - .getList(perPage: 50) - .then((docs) { - if (!mounted) { - return; - } - - for (final item in docs.items) { - _commandItems.add({ - 'id': item.id, - 'title': item.getStringValue("title"), - 'description': item.getStringValue("description"), - }); - } - - setState(() { - _isLoading = false; - }); - }).catchError((err) { - setState(() { - _isLoading = false; - }); - }); - } - - void attachItem() async { - FilePickerResult? result = await FilePicker.platform.pickFiles( - allowMultiple: true, - type: FileType.custom, - allowedExtensions: [ - "pdf", - "png", - "bpm", - "jpeg", - "jpg", - ], - ); - - if ((result?.count ?? 0) == 0) { - widget.onClose(); - return; - } - - final images = await ocrFiles(result!.files); - Map files = {}; - - for (final (index, image) in images.indexed) { - files[result.files[index].name] = image; - } - - widget.onClose( - files: files, - ); - } - - @override - Widget build(BuildContext context) { - return ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Material( - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 16), - decoration: BoxDecoration( - color: Theme.of(context).cardColor, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Colors.grey.withOpacity(0.2), - ), - ), - child: ListView.builder( - shrinkWrap: true, - padding: EdgeInsets.zero, - itemCount: _commandItems.length + (_isLoading ? 1 : 0), - itemBuilder: (context, index) { - if (index == _commandItems.length) { - return Container( - padding: const EdgeInsets.only( - bottom: 24, - ), - child: const Center( - child: CircularProgressIndicator(), - ), - ); - } - - final item = _commandItems[index]; - return ListTile( - selected: item['id'] == widget.actionId, - leading: Icon( - item['id'] == 'file-upload' - ? Icons.file_present - : Icons.chat_bubble_outline, - ), - title: Text(item['title'] ?? ''), - subtitle: Text( - item['description'] ?? '', - maxLines: 3, - overflow: TextOverflow.ellipsis, - ), - onTap: () { - if (item['id'] == 'file-upload') { - return attachItem(); - } - - widget.onClose( - actionId: item['id'], - ); - }, - ); - }, - ), - ), - ), - ); - } -} diff --git a/lib/features/chat/container/chat_bubble.dart b/lib/features/chat/container/chat_bubble.dart deleted file mode 100644 index a5c4453..0000000 --- a/lib/features/chat/container/chat_bubble.dart +++ /dev/null @@ -1,158 +0,0 @@ -import 'dart:ui'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; - -class ChatBubble extends StatelessWidget { - final String message; - final bool isUser; - final bool isComplete; - final VoidCallback? onCancel; - final bool isStreaming = false; - final int length; - - const ChatBubble({ - super.key, - required this.message, - required this.isUser, - this.isComplete = true, - this.onCancel, - this.length = 0, - }); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 4.0), - child: Column( - crossAxisAlignment: - isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: - isUser ? MainAxisAlignment.end : MainAxisAlignment.start, - children: [ - const SizedBox(width: 8), - Flexible( - child: Container( - decoration: BoxDecoration( - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 5, - offset: const Offset(0, 2), - ), - ], - ), - child: GestureDetector( - onTap: () { - showDialog( - context: context, - builder: (context) => BackdropFilter( - filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5), - child: AlertDialog( - contentPadding: const EdgeInsets.only( - bottom: 12, - ), - insetPadding: const EdgeInsets.all(10), - title: const Text("Message"), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Close'), - ), - TextButton.icon( - onPressed: () { - Clipboard.setData( - ClipboardData(text: message), - ); - }, - label: const Text('Copy'), - icon: const Icon(Icons.copy), - ), - ], - content: Container( - constraints: BoxConstraints( - maxHeight: - MediaQuery.of(context).size.height - 220, - ), - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(20.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 20), - MarkdownBody( - data: message, - selectable: true, - ), - ], - ), - ), - ), - ), - ), - ), - ); - }, - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: isUser - ? Theme.of(context).primaryColor.withOpacity(0.9) - : Theme.of(context).cardColor, - borderRadius: BorderRadius.circular(20), - border: !isUser - ? Border.all(color: Colors.grey.withOpacity(0.2)) - : null, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - message.trim().isEmpty && isUser - ? Column( - children: [ - const Icon(Icons.file_present), - Text("Files Attached $length"), - ], - ) - : MarkdownBody( - data: message, - styleSheet: MarkdownStyleSheet( - p: TextStyle( - color: isUser - ? Colors.white - : Theme.of(context) - .textTheme - .bodyLarge - ?.color, - fontSize: 16, - ), - ), - ), - ], - ), - ), - ), - ), - ), - if (isUser) _buildAvatar(true), - ], - ), - ], - ), - ); - } - - Widget _buildAvatar(bool isUser) { - return CircleAvatar( - backgroundColor: isUser ? Colors.blue.shade700 : Colors.grey.shade900, - child: Icon( - isUser ? Icons.person : Icons.auto_awesome, - color: Colors.white, - ), - ); - } -} diff --git a/lib/features/chat/container/chat_container.dart b/lib/features/chat/container/chat_container.dart deleted file mode 100644 index b36f15f..0000000 --- a/lib/features/chat/container/chat_container.dart +++ /dev/null @@ -1,150 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:madari_client/features/chat/container/chat_empty_state.dart'; - -import 'chat_bubble.dart'; -import 'chat_input_area.dart'; - -class ChatMessage { - final String message; - final bool isUser; - final bool isComplete; - final List files; - final CancellationToken? cancellationToken; - final String? actionId; - - ChatMessage({ - required this.message, - required this.isUser, - this.files = const [], - this.isComplete = true, - this.cancellationToken, - this.actionId, - }); -} - -class CancellationToken { - bool _isCancelled = false; - bool get isCancelled => _isCancelled; - void cancel() => _isCancelled = true; -} - -class ChatContainer extends StatefulWidget { - final List? initialMessages; - final Future Function(String, List?, String?)? onSendMessage; - final ScrollController scrollController; - - const ChatContainer({ - super.key, - this.initialMessages, - this.onSendMessage, - required this.scrollController, - }); - - @override - State createState() => _ChatContainerState(); -} - -class _ChatContainerState extends State { - List messages = []; - bool isLoading = false; - final FocusNode _focusNode = FocusNode(); - bool _isExpanded = false; - - @override - void initState() { - super.initState(); - if (widget.initialMessages != null) { - messages = widget.initialMessages!; - } - _focusNode.addListener(() { - setState(() { - _isExpanded = _focusNode.hasFocus; - }); - }); - } - - Future _handleSubmit( - String text, - String? actionId, - List files, - ) async { - if (text.trim().isEmpty && actionId == null) return; - - setState(() { - isLoading = true; - }); - - if (widget.onSendMessage != null) { - await widget.onSendMessage!(text, files, actionId); - } - - setState(() { - isLoading = false; - files = []; - }); - } - - @override - void dispose() { - _focusNode.dispose(); - super.dispose(); - } - - Widget _buildEmptyState() { - return ChatEmpty( - handleSubmit: (text) => _handleSubmit( - text, - null, - [], - ), - ); - } - - @override - Widget build(BuildContext context) { - return Column( - children: [ - Expanded( - child: messages.isEmpty - ? _buildEmptyState() - : Container( - constraints: const BoxConstraints( - maxWidth: 1100, - ), - child: ListView.builder( - controller: widget.scrollController, - padding: const EdgeInsets.all(8.0), - itemCount: messages.length, - itemBuilder: (context, index) { - final message = messages[index]; - return ChatBubble( - message: message.message, - isUser: message.isUser, - isComplete: message.isComplete, - length: message.files.length, - ); - }, - ), - ), - ), - Center( - child: Container( - constraints: const BoxConstraints( - maxWidth: 1100, - ), - child: ChatInputArea( - isLoading: isLoading, - onSubmitted: (text, files, actionId) => _handleSubmit( - text, - actionId, - files, - ), - focusNode: _focusNode, - cancellationToken: messages.lastOrNull?.cancellationToken, - ), - ), - ), - ], - ); - } -} diff --git a/lib/features/chat/container/chat_empty_state.dart b/lib/features/chat/container/chat_empty_state.dart deleted file mode 100644 index dc2d4ae..0000000 --- a/lib/features/chat/container/chat_empty_state.dart +++ /dev/null @@ -1,207 +0,0 @@ -import 'package:flutter/material.dart'; - -class ChatEmpty extends StatefulWidget { - final Function(String input) handleSubmit; - - const ChatEmpty({ - super.key, - required this.handleSubmit, - }); - - @override - State createState() => _ChatEmptyState(); -} - -class _ChatEmptyState extends State { - @override - Widget build(BuildContext context) { - final suggestions = [ - "📚 How to create a study schedule?", - "🧠 Best memory techniques for exams", - "⏰ Time management tips for exam prep", - "📝 Practice test strategies", - "📱 Best study apps and tools", - "🎯 How to stay focused while studying", - "💡 Active recall techniques", - "📊 Spaced repetition methods", - "🌟 Exam day preparation tips", - "✍️ Note-taking strategies", - "🧘‍♂️ Study break activities", - "👥 Group study benefits", - ]; - - final isDarkMode = Theme.of(context).brightness == Brightness.dark; - final primaryColor = - isDarkMode ? Colors.blue[400]! : Theme.of(context).primaryColor; - - return Center( - child: SingleChildScrollView( - child: Center( - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // Animated gradient container - Container( - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - primaryColor.withOpacity(0.3), - primaryColor.withOpacity(0.1), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(24), - border: Border.all( - color: primaryColor.withOpacity(0.2), - width: 1, - ), - boxShadow: [ - BoxShadow( - color: primaryColor.withOpacity(0.1), - blurRadius: 20, - offset: const Offset(0, 10), - ), - ], - ), - child: Icon( - Icons.chat_bubble_outline, - size: 48, - color: primaryColor, - ), - ), - const SizedBox(height: 32), - Text( - 'How can I help you today?', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - color: isDarkMode ? Colors.white : primaryColor, - ), - ), - const SizedBox(height: 12), - Text( - 'Choose a suggestion or type your own question', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: isDarkMode ? Colors.grey[400] : Colors.grey[600], - ), - ), - const SizedBox(height: 32), - SizedBox( - height: 100, - child: Column( - children: [ - Expanded( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: suggestions - .sublist(0, suggestions.length ~/ 2) - .map((suggestion) => Padding( - padding: const EdgeInsets.only(right: 12), - child: _buildSuggestionChip( - suggestion, - isDarkMode: isDarkMode, - primaryColor: primaryColor, - ), - )) - .toList(), - ), - ), - ), - Expanded( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: suggestions - .sublist(suggestions.length ~/ 2) - .map((suggestion) => Padding( - padding: const EdgeInsets.only(right: 12), - child: _buildSuggestionChip( - suggestion, - isDarkMode: isDarkMode, - primaryColor: primaryColor, - ), - )) - .toList(), - ), - ), - ), - ], - ), - ), - ], - ), - ), - ), - ), - ); - } - - Widget _buildSuggestionChip( - String suggestion, { - required bool isDarkMode, - required Color primaryColor, - }) { - return Material( - color: Colors.transparent, - child: InkWell( - onTap: () => widget.handleSubmit(suggestion), - borderRadius: BorderRadius.circular(16), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - gradient: LinearGradient( - colors: [ - isDarkMode ? Colors.grey[850]! : Theme.of(context).cardColor, - isDarkMode - ? Colors.grey[900]! - : Theme.of(context).cardColor.withOpacity(0.9), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - border: Border.all( - color: isDarkMode - ? Colors.grey[800]! - : primaryColor.withOpacity(0.1), - ), - boxShadow: [ - if (!isDarkMode) - BoxShadow( - color: primaryColor.withOpacity(0.1), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.lightbulb_outline, - size: 16, - color: primaryColor, - ), - const SizedBox(width: 8), - Flexible( - child: Text( - suggestion, - style: TextStyle( - color: isDarkMode ? Colors.grey[300] : primaryColor, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/features/chat/container/chat_history.dart b/lib/features/chat/container/chat_history.dart deleted file mode 100644 index 5d95586..0000000 --- a/lib/features/chat/container/chat_history.dart +++ /dev/null @@ -1,157 +0,0 @@ -import 'package:cached_query_flutter/cached_query_flutter.dart'; -import 'package:flutter/material.dart'; -import 'package:pocketbase/pocketbase.dart'; - -import '../../../engine/engine.dart'; - -class ChatHistory extends StatefulWidget { - const ChatHistory({super.key}); - - @override - State createState() => _ChatHistoryState(); -} - -class _ChatHistoryState extends State { - final pb = AppEngine.engine.pb; - late final InfiniteQuery, int> chatHistoryQuery; - final ScrollController _scrollController = ScrollController(); - - @override - void initState() { - super.initState(); - chatHistoryQuery = InfiniteQuery, int>( - key: "chat_history", - queryFn: (page) async { - try { - final result = await pb.collection("chat").getList( - page: page, - perPage: 10, // Adjust perPage as needed - sort: '-created', // Assuming you want the latest chats first - ); - return result.items.toList(); - } catch (e) { - debugPrint('Error fetching chat history: $e'); - throw e; - } - }, - getNextArg: (state) { - if (state.lastPage?.isEmpty ?? false) return null; - return state.length; - }, - ); - } - - @override - void dispose() { - _scrollController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Drawer( - child: SafeArea( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Padding( - padding: const EdgeInsets.all(16.0), - child: Text( - "Chat History", - style: Theme.of(context).textTheme.titleLarge, - ), - ), - const Spacer(), - TextButton.icon( - onPressed: () { - pb.collection("chat").create( - body: {}, - ); - }, - icon: const Icon( - Icons.create_new_folder_outlined, - ), - label: const Text("New Chat"), - ), - const SizedBox( - width: 8, - ), - ], - ), - const Divider(), - Expanded( - child: InfiniteQueryBuilder( - query: chatHistoryQuery, - builder: (ctx, state, query) { - if (state.status == QueryStatus.loading && - state.data == null) { - return const Center(child: CircularProgressIndicator()); - } - - if (state.status == QueryStatus.error) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - 'Failed to load chat history.', - style: TextStyle(color: Colors.red.shade700), - ), - const SizedBox(height: 8), - ElevatedButton( - onPressed: query.refetch, - child: const Text('Retry'), - ), - ], - ), - ); - } - - final items = state.data?.expand((e) => e).toList() ?? []; - - if (items.isEmpty) { - return const Center(child: Text("No chat history yet.")); - } - - return RefreshIndicator( - onRefresh: query.refetch, - child: ListView.separated( - controller: _scrollController, - itemCount: items.length + (!state.hasReachedMax ? 1 : 0), - separatorBuilder: (_, __) => const Divider(), - itemBuilder: (context, index) { - if (index < items.length) { - final chat = items[index]; - return ListTile( - title: Text( - chat.getStringValue("title") ?? "Untitled Chat", - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - subtitle: Text( - 'Created: ${chat.created}', // Display creation date - style: Theme.of(context).textTheme.bodyLarge, - ), - ); - } else if (!query.hasReachedMax()) { - return const Padding( - padding: EdgeInsets.all(16.0), - child: Center(child: CircularProgressIndicator()), - ); - } else { - return const SizedBox.shrink(); - } - }, - ), - ); - }, - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/features/chat/container/chat_input_area.dart b/lib/features/chat/container/chat_input_area.dart deleted file mode 100644 index 2b57f75..0000000 --- a/lib/features/chat/container/chat_input_area.dart +++ /dev/null @@ -1,328 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:madari_client/engine/engine.dart'; -import 'package:madari_client/features/chat/container/chat_container.dart'; - -import 'chat_action.dart'; - -class ChatInputArea extends StatefulWidget { - final bool isLoading; - final Future Function( - String text, - List files, - String? actionId, - ) onSubmitted; - final FocusNode focusNode; - final CancellationToken? cancellationToken; - - const ChatInputArea({ - super.key, - required this.isLoading, - required this.onSubmitted, - required this.focusNode, - this.cancellationToken, - }); - - @override - State createState() => _ChatInputAreaState(); -} - -class _ChatInputAreaState extends State { - final TextEditingController _textController = TextEditingController(); - final LayerLink _layerLink = LayerLink(); - OverlayEntry? _overlayEntry; - final Map files = - {}; // key is file name and string is the content - String? actionId; - - @override - void initState() { - super.initState(); - _textController.addListener(_handleTextChange); - - AppEngine.engine.pb - .collection("ai_action") - .getList(perPage: 50) - .then((docs) {}); - } - - @override - void dispose() { - _hideCommandPalette(); - _textController.removeListener(_handleTextChange); - super.dispose(); - } - - void _handleTextChange() { - if (_textController.text == '/') { - _showCommandPalette(); - } else if (_textController.text.isEmpty) { - _hideCommandPalette(); - } - } - - void _showCommandPalette() { - _hideCommandPalette(); - - _overlayEntry = OverlayEntry( - builder: (context) => Positioned( - width: MediaQuery.of(context).size.width, - child: CompositedTransformFollower( - link: _layerLink, - showWhenUnlinked: false, - offset: const Offset(0, -5), - targetAnchor: Alignment.topCenter, - followerAnchor: Alignment.bottomCenter, - child: TweenAnimationBuilder( - duration: const Duration(milliseconds: 200), - curve: Curves.easeOutCubic, - tween: Tween(begin: 0, end: 1), - builder: (context, value, child) => Transform.scale( - scale: value, - child: Opacity( - opacity: value, - child: child, - ), - ), - child: ChatAction( - actionId: actionId, - onClose: ({ - actionId, - files, - }) { - _textController.clear(); - _hideCommandPalette(); - - setState(() { - this.files.addAll(files ?? {}); - if (actionId != null) this.actionId = actionId; - }); - }, - ), - ), - ), - ), - ); - - Overlay.of(context).insert(_overlayEntry!); - - setState(() {}); - } - - void _hideCommandPalette() { - _overlayEntry?.remove(); - _overlayEntry = null; - if (mounted && context.mounted) { - setState(() {}); - } - } - - @override - Widget build(BuildContext context) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (files.isNotEmpty) - Container( - height: 40, - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: ListView.builder( - scrollDirection: Axis.horizontal, - itemCount: files.length, - itemBuilder: (context, index) { - final fileName = files.keys.elementAt(index); - return Padding( - padding: const EdgeInsets.only(right: 8.0), - child: GestureDetector( - onTap: () { - showDialog( - context: context, - builder: (context) => AlertDialog( - insetPadding: const EdgeInsets.all(4), - actions: [ - TextButton.icon( - onPressed: () { - Navigator.of(context).pop(); - }, - label: const Text("Close"), - ), - TextButton.icon( - onPressed: () { - Navigator.of(context).pop(); - }, - label: const Text("Copy"), - icon: const Icon(Icons.copy), - ), - ], - title: Text( - fileName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - content: SingleChildScrollView( - child: Text( - files[fileName]!, - ), - ), - ), - ); - }, - child: Chip( - visualDensity: VisualDensity.compact, - label: Row( - children: [ - const Icon( - Icons.picture_as_pdf_outlined, - size: 16, - ), - const SizedBox( - width: 12, - ), - Container( - constraints: const BoxConstraints( - maxWidth: 100, - ), - child: Text( - fileName, - style: const TextStyle( - fontSize: 12, - ), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - onDeleted: () { - setState(() { - files.remove(fileName); - }); - }, - deleteIcon: const Icon(Icons.close, size: 16), - backgroundColor: - Theme.of(context).primaryColor.withOpacity(0.1), - ), - ), - ); - }, - ), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), - decoration: BoxDecoration( - color: Theme.of(context).cardColor, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - IconButton( - icon: _overlayEntry == null - ? const Icon(Icons.add_circle_outline) - : const Icon(Icons.close), - onPressed: _overlayEntry == null - ? _showCommandPalette - : _hideCommandPalette, - tooltip: 'Open commands', - padding: EdgeInsets.zero, - constraints: const BoxConstraints( - minWidth: 32, - minHeight: 32, - ), - ), - const SizedBox( - width: 8, - ), - Expanded( - child: CompositedTransformTarget( - link: _layerLink, - child: Container( - constraints: const BoxConstraints( - minHeight: 40, - maxHeight: 120, - ), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - border: Border.all(color: Colors.grey.withOpacity(0.2)), - color: Theme.of(context).scaffoldBackgroundColor, - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: TextField( - controller: _textController, - focusNode: widget.focusNode, - maxLines: null, - decoration: const InputDecoration( - hintText: 'Message...', - border: InputBorder.none, - hintStyle: TextStyle(color: Colors.grey), - ), - onSubmitted: (value) async { - await widget.onSubmitted( - value, - files.values.toList(), - actionId, - ); - setState(() { - files.clear(); - actionId = null; - }); - _textController.clear(); - }, - enabled: !widget.isLoading, - ), - ), - ), - ), - ), - const SizedBox(width: 8), - Container( - margin: const EdgeInsets.only(bottom: 2), - child: AnimatedScale( - duration: const Duration(milliseconds: 200), - scale: _textController.text.isEmpty ? 0.8 : 1.0, - child: Container( - height: 40, - width: 40, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: widget.isLoading - ? Colors.grey - : Theme.of(context).primaryColor.withOpacity(0.9), - ), - child: widget.cancellationToken == null || - widget.cancellationToken?.isCancelled == true - ? IconButton( - padding: EdgeInsets.zero, - icon: const Icon(Icons.send, - color: Colors.white, size: 20), - onPressed: widget.isLoading - ? null - : () async { - await widget.onSubmitted( - _textController.text, - files.values.toList(), - actionId, - ); - setState(() { - files.clear(); - actionId = null; - }); - _textController.clear(); - }, - ) - : IconButton( - onPressed: () { - widget.cancellationToken?.cancel(); - }, - icon: const Icon( - Icons.stop_circle, - ), - ), - ), - ), - ), - ], - ), - ), - ], - ); - } -} diff --git a/lib/features/collection/container/add_collection_item.dart b/lib/features/collection/container/add_collection_item.dart deleted file mode 100644 index 41c397f..0000000 --- a/lib/features/collection/container/add_collection_item.dart +++ /dev/null @@ -1,217 +0,0 @@ -import 'package:file_picker/file_picker.dart'; -import 'package:flutter/material.dart'; -import 'package:madari_client/utils/ocr_file.dart'; - -import '../service/service.dart'; - -class AddCollectionItemSheet extends StatefulWidget { - final String listId; - - const AddCollectionItemSheet({ - super.key, - required this.listId, - }); - - @override - State createState() => _AddCollectionItemSheetState(); -} - -class _AddCollectionItemSheetState extends State - with SingleTickerProviderStateMixin { - final _titleController = TextEditingController(); - late TabController _tabController; - PlatformFile? _selectedFile; - String? _fileType; - - @override - void initState() { - super.initState(); - _tabController = TabController(length: 2, vsync: this); - } - - @override - void dispose() { - _titleController.dispose(); - _tabController.dispose(); - super.dispose(); - } - - Future _pickFile() async { - try { - FilePickerResult? result = await FilePicker.platform.pickFiles( - type: FileType.custom, - allowedExtensions: ['pdf', 'jpg', 'jpeg', 'png'], - withData: true, - ); - - if (result != null) { - setState(() { - _selectedFile = result.files.single; - _fileType = result.files.single.extension; - _titleController.text = result.files.single.name; - }); - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Error picking file: $e')), - ); - } - } - } - - bool _isLoading = false; - - Future _saveItem() async { - if (_titleController.text.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Please enter a title')), - ); - return; - } - if (_selectedFile == null) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Please select a file')), - ); - return; - } - - setState(() { - _isLoading = true; - }); - - try { - await CollectionService.addItem( - listId: widget.listId, - name: _titleController.text, - type: "file", - file: _selectedFile!, - content: (await ocrFiles([_selectedFile!])).first, - ); - - if (mounted) { - Navigator.pop(context, true); - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Error: $e')), - ); - } - } finally { - setState(() { - _isLoading = false; - }); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Add New Item'), - leading: IconButton( - icon: const Icon(Icons.close), - onPressed: () => Navigator.pop(context), - ), - ), - floatingActionButton: FloatingActionButton.extended( - label: _isLoading ? const Text("Uploading...") : const Text("Upload"), - icon: _isLoading - ? const SizedBox( - height: 24, - width: 24, - child: CircularProgressIndicator(), - ) - : const Icon(Icons.upload_file), - onPressed: _isLoading ? null : _saveItem, - ), - body: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _buildFileUploadTab(), - ], - ), - ); - } - - Widget _buildFileUploadTab() { - return SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (_selectedFile != null) - TextField( - controller: _titleController, - decoration: const InputDecoration( - labelText: 'Title', - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 24), - _selectedFile == null - ? Center( - child: Column( - children: [ - const Icon(Icons.upload_file, - size: 48, color: Colors.grey), - const SizedBox(height: 16), - ElevatedButton.icon( - onPressed: _pickFile, - icon: const Icon(Icons.add), - label: const Text('Select File'), - ), - const SizedBox(height: 8), - const Text( - 'Supported formats: PDF, JPG, PNG', - style: TextStyle(color: Colors.grey), - ), - ], - ), - ) - : Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - _fileType == 'pdf' - ? Icons.picture_as_pdf - : Icons.image, - color: Colors.blue, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - _selectedFile!.path?.split('/').last ?? "", - style: const TextStyle( - fontWeight: FontWeight.bold), - ), - ), - IconButton( - icon: const Icon(Icons.close), - onPressed: () => setState(() { - _selectedFile = null; - _fileType = null; - }), - ), - ], - ), - const SizedBox(height: 8), - Text( - 'File type: ${_fileType?.toUpperCase()}', - style: const TextStyle(color: Colors.grey), - ), - ], - ), - ), - ), - ], - ), - ); - } -} diff --git a/lib/features/collection/container/collection_item_renderer.dart b/lib/features/collection/container/collection_item_renderer.dart deleted file mode 100644 index 9386f5b..0000000 --- a/lib/features/collection/container/collection_item_renderer.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../types/collection_item_model.dart'; -import 'collection_markdown_renderer.dart'; - -class CollectionItemRenderer extends StatelessWidget { - final CollectionItemModel item; - - const CollectionItemRenderer({ - super.key, - required this.item, - }); - - @override - Widget build(BuildContext context) { - switch (item.type) { - case 'markdown': - return MarkdownRenderer(content: item.content?['text'] ?? ''); - // case 'file': - // return FileRenderer(filePath: item.file!); - default: - return Text('Unsupported type: ${item.type}'); - } - } -} diff --git a/lib/features/collection/container/collection_list_item_list.dart b/lib/features/collection/container/collection_list_item_list.dart deleted file mode 100644 index 8b6f5ef..0000000 --- a/lib/features/collection/container/collection_list_item_list.dart +++ /dev/null @@ -1,237 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; -import 'package:madari_client/features/doc_viewer/container/doc_viewer.dart'; -import 'package:madari_client/features/doc_viewer/types/doc_source.dart'; - -import '../../../engine/engine.dart'; -import '../service/service.dart'; -import '../types/collection_item_model.dart'; -import 'add_collection_item.dart'; -import 'collection_markdown_renderer.dart'; -import 'collection_search_delegate.dart'; - -class CollectionListItemsScreen extends StatefulWidget { - final String listId; - final bool isPublic; - final String title; - - const CollectionListItemsScreen({ - super.key, - required this.listId, - required this.isPublic, - required this.title, - }); - - @override - State createState() => - _CollectionListItemsScreenState(); -} - -class _CollectionListItemsScreenState extends State { - String _sortBy = 'created'; - bool _ascending = false; - late Future> _itemsFuture; - - Future _showAddItemSheet() async { - final result = await showModalBottomSheet( - context: context, - builder: (context) => AddCollectionItemSheet(listId: widget.listId), - ); - - if (result == true) { - setState(() => _refreshItems()); - } - } - - @override - void initState() { - super.initState(); - _refreshItems(); - } - - void _refreshItems() { - _itemsFuture = CollectionService.getCollectionItems( - listId: widget.listId, - searchQuery: '', - sortBy: _sortBy, - ascending: _ascending, - ); - } - - @override - Widget build(BuildContext context) { - return FutureBuilder>( - future: _itemsFuture, - builder: (context, snapshot) { - return Scaffold( - floatingActionButton: snapshot.hasData && - !widget.isPublic && - !kIsWeb && - (Platform.isIOS || Platform.isAndroid) - ? FloatingActionButton( - onPressed: _showAddItemSheet, - child: const Icon(Icons.add), - ) - : null, - appBar: AppBar( - title: Text(widget.title), - actions: [ - // Search action - IconButton( - icon: const Icon(Icons.search), - onPressed: () { - showSearch( - context: context, - delegate: CollectionSearchDelegate(widget.listId), - ); - }, - ), - // Sort menu - PopupMenuButton( - icon: const Icon(Icons.sort), - onSelected: (value) { - setState(() { - if (value == _sortBy) { - _ascending = !_ascending; - } else { - _sortBy = value; - } - _refreshItems(); - }); - }, - itemBuilder: (context) => [ - CheckedPopupMenuItem( - value: 'created', - checked: _sortBy == 'created', - child: Row( - children: [ - const Text('Created'), - if (_sortBy == 'created') - Icon(_ascending - ? Icons.arrow_upward - : Icons.arrow_downward), - ], - ), - ), - CheckedPopupMenuItem( - value: 'updated', - checked: _sortBy == 'updated', - child: Row( - children: [ - const Text('Updated'), - if (_sortBy == 'updated') - Icon(_ascending - ? Icons.arrow_upward - : Icons.arrow_downward), - ], - ), - ), - ], - ), - // Refresh button - IconButton( - icon: const Icon(Icons.refresh), - onPressed: () => setState(() => _refreshItems()), - ), - ], - ), - body: FutureBuilder( - future: Future.delayed(Duration.zero), - builder: (ctx, _) { - if (snapshot.hasError) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.error_outline, - size: 48, color: Colors.red), - const SizedBox(height: 16), - Text('Error: ${snapshot.error}'), - ], - ), - ); - } - - if (!snapshot.hasData) { - return const Center(child: CircularProgressIndicator()); - } - - final items = snapshot.data!; - - if (items.isEmpty) { - return const Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.inbox, size: 48, color: Colors.grey), - SizedBox(height: 16), - Text('No items found'), - ], - ), - ); - } - - return ListView.builder( - padding: const EdgeInsets.all(8), - itemCount: items.length, - itemBuilder: (context, index) { - final item = items[index]; - return ListTile( - onTap: () async { - if (item.file != null && item.file != "") { - final fileToken = - await AppEngine.engine.pb.files.getToken(); - - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) { - return DocViewer( - source: URLSource( - title: item.name, - url: "${item.file}?token=$fileToken", - id: item.id, - ), - ); - }, - ), - ); - return; - } - - final file = await AppEngine.engine.pb - .collection("collection_item") - .getOne(item.id); - - if (context.mounted && mounted) { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (context) => FullMarkdownSheet( - content: file.getStringValue("content") ?? "", - ), - ); - } - }, - leading: item.type == "markdown" - ? const Icon(Icons.document_scanner_outlined) - : const Icon(Icons.file_present), - title: Text(item.name), - subtitle: Text(formatDate(item.updated)), - ); - }, - ); - }, - ), - ); - }, - ); - } - - String formatDate(DateTime created) { - return DateFormat("dd MMM yyyy").format(created); - } -} diff --git a/lib/features/collection/container/collection_markdown_renderer.dart b/lib/features/collection/container/collection_markdown_renderer.dart deleted file mode 100644 index a7db6c8..0000000 --- a/lib/features/collection/container/collection_markdown_renderer.dart +++ /dev/null @@ -1,155 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; - -class MarkdownRenderer extends StatelessWidget { - final String content; - final int previewLines; - - const MarkdownRenderer({ - super.key, - required this.content, - this.previewLines = 3, - }); - - @override - Widget build(BuildContext context) { - final String previewText = - content.split('\n').take(previewLines).join('\n'); - - final bool hasMore = content.split('\n').length > previewLines; - - return InkWell( - onTap: () => _showFullMarkdown(context), - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MarkdownBody( - data: previewText, - shrinkWrap: true, - styleSheet: MarkdownStyleSheet( - p: Theme.of(context).textTheme.bodyMedium, - h1: Theme.of(context).textTheme.headlineSmall, - h2: Theme.of(context).textTheme.titleLarge, - ), - ), - if (hasMore) ...[ - const SizedBox(height: 8), - Text( - 'Tap to read more...', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.primary, - ), - ), - ], - ], - ), - ), - ); - } - - void _showFullMarkdown(BuildContext context) { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (context) => FullMarkdownSheet(content: content), - ); - } -} - -class FullMarkdownSheet extends StatelessWidget { - final String content; - - const FullMarkdownSheet({ - super.key, - required this.content, - }); - - @override - Widget build(BuildContext context) { - return DraggableScrollableSheet( - initialChildSize: 0.9, - minChildSize: 0.5, - maxChildSize: 0.95, - builder: (context, scrollController) { - return Container( - decoration: BoxDecoration( - color: Theme.of(context).scaffoldBackgroundColor, - borderRadius: const BorderRadius.vertical( - top: Radius.circular(16), - ), - ), - child: Column( - children: [ - // Handle bar - Container( - margin: const EdgeInsets.symmetric(vertical: 8), - width: 40, - height: 4, - decoration: BoxDecoration( - color: Colors.grey[300], - borderRadius: BorderRadius.circular(2), - ), - ), - // Actions bar - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - IconButton( - icon: const Icon(Icons.close), - onPressed: () => Navigator.pop(context), - ), - Row( - children: [ - IconButton( - icon: const Icon(Icons.copy), - onPressed: () { - // Copy to clipboard - // You might want to add feedback when copied - }, - ), - IconButton( - icon: const Icon(Icons.share), - onPressed: () { - // Implement share functionality - }, - ), - ], - ), - ], - ), - ), - // Markdown content - Expanded( - child: Markdown( - controller: scrollController, - data: content, - styleSheet: MarkdownStyleSheet( - p: Theme.of(context).textTheme.bodyLarge, - h1: Theme.of(context).textTheme.headlineMedium, - h2: Theme.of(context).textTheme.headlineSmall, - h3: Theme.of(context).textTheme.titleLarge, - code: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontFamily: 'monospace', - backgroundColor: Colors.grey[200], - ), - codeblockDecoration: BoxDecoration( - color: Colors.grey[200], - borderRadius: BorderRadius.circular(8), - ), - ), - selectable: true, - padding: const EdgeInsets.all(16), - ), - ), - ], - ), - ); - }, - ); - } -} diff --git a/lib/features/collection/container/collection_search_delegate.dart b/lib/features/collection/container/collection_search_delegate.dart deleted file mode 100644 index 133e829..0000000 --- a/lib/features/collection/container/collection_search_delegate.dart +++ /dev/null @@ -1,149 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; - -import '../../../engine/engine.dart'; -import '../../doc_viewer/container/doc_viewer.dart'; -import '../../doc_viewer/types/doc_source.dart'; -import '../service/service.dart'; -import '../types/collection_item_model.dart'; -import 'collection_markdown_renderer.dart'; - -class CollectionSearchDelegate extends SearchDelegate { - final String listId; - - CollectionSearchDelegate(this.listId); - - @override - List buildActions(BuildContext context) { - return [ - IconButton( - icon: const Icon(Icons.clear), - onPressed: () { - query = ''; - }, - ), - ]; - } - - @override - Widget buildLeading(BuildContext context) { - return IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () { - close(context, ''); - }, - ); - } - - @override - Widget buildResults(BuildContext context) { - return _buildSearchResults(); - } - - @override - Widget buildSuggestions(BuildContext context) { - return _buildSearchResults(); - } - - Widget _buildSearchResults() { - if (query.isEmpty) { - return const Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.search, size: 64, color: Colors.grey), - SizedBox(height: 16), - Text('Start typing to search'), - ], - ), - ); - } - - return FutureBuilder>( - future: CollectionService.getCollectionItems( - listId: listId, - searchQuery: query, - sortBy: 'created', - ascending: false, - ), - builder: (context, snapshot) { - if (snapshot.hasError) { - return Center(child: Text('Error: ${snapshot.error}')); - } - - if (!snapshot.hasData) { - return const Center(child: CircularProgressIndicator()); - } - - final items = snapshot.data!; - - if (items.isEmpty) { - return const Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.search_off, size: 64, color: Colors.grey), - SizedBox(height: 16), - Text('No results found'), - ], - ), - ); - } - - return ListView.builder( - padding: const EdgeInsets.all(8), - itemCount: items.length, - itemBuilder: (context, index) { - final item = items[index]; - return ListTile( - onTap: () async { - if (item.file != null && item.file != "") { - final fileToken = await AppEngine.engine.pb.files.getToken(); - - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) { - return DocViewer( - source: URLSource( - title: item.name, - url: "${item.file}?token=$fileToken", - id: item.id, - ), - ); - }, - ), - ); - return; - } - - final file = await AppEngine.engine.pb - .collection("collection_item") - .getOne(item.id); - - if (context.mounted) { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (context) => FullMarkdownSheet( - content: file.getStringValue("content") ?? "", - ), - ); - } - }, - leading: item.type == "markdown" - ? const Icon(Icons.document_scanner_outlined) - : const Icon(Icons.file_present), - title: Text(item.name), - subtitle: Text(formatDate(item.updated)), - ); - }, - ); - }, - ); - } - - String formatDate(DateTime created) { - return DateFormat("dd MMM yyyy").format(created); - } -} diff --git a/lib/features/collection/container/create_new_collection.dart b/lib/features/collection/container/create_new_collection.dart deleted file mode 100644 index 76cba62..0000000 --- a/lib/features/collection/container/create_new_collection.dart +++ /dev/null @@ -1,147 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../../engine/engine.dart'; - -class CreateCollectionBottomSheet extends StatefulWidget { - final Function() onCollectionCreated; - - const CreateCollectionBottomSheet({ - super.key, - required this.onCollectionCreated, - }); - - @override - State createState() => - _CreateCollectionBottomSheetState(); -} - -class _CreateCollectionBottomSheetState - extends State { - final _formKey = GlobalKey(); - final _nameController = TextEditingController(); - final _descriptionController = TextEditingController(); - bool _isPublic = false; - bool _isLoading = false; - - @override - void dispose() { - _nameController.dispose(); - _descriptionController.dispose(); - super.dispose(); - } - - Future _createCollection() async { - if (!_formKey.currentState!.validate()) return; - - setState(() => _isLoading = true); - - try { - final pb = AppEngine.engine.pb; - - await pb.collection('collection').create(body: { - 'name': _nameController.text.trim(), - 'description': _descriptionController.text.trim(), - 'is_public': _isPublic, - 'order': 0, - 'user': pb.authStore.record!.id, - }); - - if (mounted) { - Navigator.pop(context); - widget.onCollectionCreated(); - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Failed to create collection: ${e.toString()}')), - ); - } - } finally { - if (mounted) { - setState(() => _isLoading = false); - } - } - } - - @override - Widget build(BuildContext context) { - return Container( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, - left: 16, - right: 16, - top: 16, - ), - child: Form( - key: _formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Create New Collection', - style: Theme.of(context).textTheme.titleLarge, - ), - IconButton( - onPressed: () => Navigator.pop(context), - icon: const Icon(Icons.close), - ), - ], - ), - const SizedBox(height: 16), - TextFormField( - controller: _nameController, - decoration: const InputDecoration( - labelText: 'Collection Name', - border: OutlineInputBorder(), - ), - validator: (value) { - if (value == null || value.trim().isEmpty) { - return 'Please enter a name'; - } - return null; - }, - textCapitalization: TextCapitalization.words, - ), - const SizedBox(height: 16), - TextFormField( - controller: _descriptionController, - decoration: const InputDecoration( - labelText: 'Description (Optional)', - border: OutlineInputBorder(), - ), - maxLines: 3, - textCapitalization: TextCapitalization.sentences, - ), - const SizedBox(height: 16), - SwitchListTile( - title: const Text('Make Public'), - subtitle: const Text('Allow others to view this collection'), - value: _isPublic, - onChanged: (value) => setState(() => _isPublic = value), - ), - const SizedBox(height: 16), - ElevatedButton( - onPressed: _isLoading ? null : _createCollection, - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - ), - child: _isLoading - ? const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Text('Create Collection'), - ), - const SizedBox(height: 16), - ], - ), - ), - ); - } -} diff --git a/lib/features/collection/service/service.dart b/lib/features/collection/service/service.dart deleted file mode 100644 index 3c0108b..0000000 --- a/lib/features/collection/service/service.dart +++ /dev/null @@ -1,116 +0,0 @@ -import 'package:file_picker/file_picker.dart'; -import 'package:http/http.dart' as http; -import 'package:path/path.dart'; - -import '../../../engine/engine.dart'; -import '../types/collection_item_model.dart'; - -class CollectionService { - static Future> getCollectionItems({ - required String listId, - String searchQuery = '', - String sortBy = 'created', - bool ascending = false, - }) async { - try { - final List filters = ['list = "$listId"']; - - if (searchQuery.isNotEmpty) { - filters.add('name ~ "$searchQuery"'); - } - - final String sort = ascending ? sortBy : '-$sortBy'; - - final result = - await AppEngine.engine.pb.collection('collection_item').getList( - filter: filters.join(' && '), - sort: sort, - fields: "file, id, name, type, updated, list, user, created", - ); - - return result.items.map( - (item) { - final res = CollectionItemModel.fromJson(item.toJson()); - - if (res.file != null) { - final url = AppEngine.engine.pb.files.getURL( - item, - item.getStringValue('file'), - ); - - res.file = url.toString().replaceFirst( - "api/files//", - "api/files/pbc_2910457697/", - ); - } - - return res; - }, - ).toList(); - } catch (e) { - print('Error fetching collection items: $e'); - rethrow; - } - } - - // Add new item - static Future addItem({ - required String listId, - required String name, - required String type, - PlatformFile? file, - dynamic content, - }) async { - final data = { - 'list': listId, - 'name': name, - 'type': type, - 'content': content, - 'user': AppEngine.engine.pb.authStore.record!.id, - }; - - final record = - await AppEngine.engine.pb.collection('collection_item').create( - body: data, - files: [ - if (file != null) - http.MultipartFile.fromBytes( - "file", - (file.bytes)!.toList(), - filename: basename(file.path!), - ), - ], - ); - - return CollectionItemModel.fromJson(record.toJson()); - } - - // Update existing item - static Future updateItem({ - required String itemId, - String? name, - String? type, - String? file, - Map? content, - }) async { - final data = { - if (name != null) 'name': name, - if (type != null) 'type': type, - if (file != null) 'file': file, - if (content != null) 'content': content, - }; - - final record = - await AppEngine.engine.pb.collection('collection_item').update( - itemId, - body: data, - ); - - return CollectionItemModel.fromJson(record.toJson()); - } - - // Delete item - static Future deleteItem(String itemId) async { - await AppEngine.engine.pb.collection('collection_item').delete(itemId); - } -} diff --git a/lib/features/collection/types/collection_item_model.dart b/lib/features/collection/types/collection_item_model.dart deleted file mode 100644 index d9b3cb8..0000000 --- a/lib/features/collection/types/collection_item_model.dart +++ /dev/null @@ -1,43 +0,0 @@ -class CollectionItemModel { - final String id; - final String name; - String? file; - final String listId; - final String userId; - final dynamic content; - final String type; - final DateTime created; - final DateTime updated; - - CollectionItemModel({ - required this.id, - required this.name, - this.file, - required this.listId, - required this.userId, - this.content, - required this.type, - required this.created, - required this.updated, - }); - - factory CollectionItemModel.fromJson(Map json) { - try { - return CollectionItemModel( - id: json['id'], - name: json['name'], - file: json['file'], - listId: json['list'], - userId: json['user'], - content: json['content'], - type: json['type'], - created: DateTime.parse(json['created']), - updated: DateTime.parse(json['updated']), - ); - } catch (e, stack) { - print(e); - print(stack); - rethrow; - } - } -} diff --git a/lib/features/collection/widgets/collection_card.dart b/lib/features/collection/widgets/collection_card.dart deleted file mode 100644 index 6a20ca4..0000000 --- a/lib/features/collection/widgets/collection_card.dart +++ /dev/null @@ -1,313 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:pocketbase/pocketbase.dart'; - -import '../../../engine/engine.dart'; -import '../container/collection_list_item_list.dart'; - -class CollectionListModel { - final String id; - final String collectionId; - final String name; - final String? description; - final int order; - final String? background; - final DateTime created; - final DateTime updated; - final String userId; - final bool isPublic; - - CollectionListModel({ - required this.id, - required this.collectionId, - required this.name, - this.description, - required this.order, - this.background, - required this.created, - required this.updated, - required this.userId, - required this.isPublic, - }); - - factory CollectionListModel.fromRecord(RecordModel record) { - return CollectionListModel( - id: record.id, - collectionId: record.collectionId, - name: record.data['name'], - description: record.data['description'], - order: record.data['order'], - background: record.data['background'], - created: DateTime.parse(record.get("created")), - updated: DateTime.parse(record.get("updated")), - userId: record.data['user'], - isPublic: record.data['isPublic'], - ); - } -} - -class CollectionCard extends StatefulWidget { - final CollectionListModel collection; - final double width; - - const CollectionCard({ - super.key, - required this.collection, - this.width = 480, - }); - - @override - State createState() => _CollectionCardState(); -} - -class _CollectionCardState extends State - with SingleTickerProviderStateMixin { - late AnimationController _controller; - late Animation _scaleAnimation; - - @override - void initState() { - super.initState(); - _controller = AnimationController( - duration: const Duration(milliseconds: 200), - vsync: this, - ); - _scaleAnimation = Tween(begin: 1.0, end: 1.05).animate( - CurvedAnimation(parent: _controller, curve: Curves.easeInOut), - ); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return MouseRegion( - onEnter: (_) => _controller.forward(), - onExit: (_) => _controller.reverse(), - child: AnimatedBuilder( - animation: _scaleAnimation, - builder: (context, child) => Transform.scale( - scale: _scaleAnimation.value, - child: child, - ), - child: Container( - width: widget.width, - height: widget.width * .6, - margin: const EdgeInsets.all(12), - child: Card( - elevation: 8, - shadowColor: Colors.black26, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - clipBehavior: Clip.antiAlias, - child: Stack( - fit: StackFit.expand, - children: [ - // Background Image or Gradient - _buildBackground(), - - // Gradient Overlay - Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.transparent, - Colors.black.withOpacity(0.3), - Colors.black.withOpacity(0.8), - ], - ), - ), - ), - Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Spacer(), - // Collection Name - Text( - widget.collection.name, - style: const TextStyle( - color: Colors.white, - fontSize: 24, - fontWeight: FontWeight.bold, - shadows: [ - Shadow( - offset: Offset(1, 1), - blurRadius: 3, - color: Colors.black45, - ), - ], - ), - ), - const SizedBox(height: 8), - // Description - if (widget.collection.description != null) - Text( - widget.collection.description!, - style: const TextStyle( - color: Colors.white70, - fontSize: 16, - shadows: [ - Shadow( - offset: Offset(1, 1), - blurRadius: 2, - color: Colors.black45, - ), - ], - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 16), - // Metadata Row - Row( - children: [ - _buildMetadataChip( - Icons.calendar_today, - _formatDate(widget.collection.updated), - ), - const Spacer(), - _buildInteractionButton(), - ], - ), - ], - ), - ), - - // Optional: Add a subtle overlay pattern - CustomPaint( - painter: PatternPainter(), - ), - - // Material Ink Effect for Ripple - Material( - color: Colors.transparent, - child: InkWell( - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (ctx) => CollectionListItemsScreen( - listId: widget.collection.id, - title: widget.collection.name, - isPublic: widget.collection.isPublic, - ), - ), - ); - }, - ), - ), - ], - ), - ), - ), - ), - ); - } - - Widget _buildBackground() { - if (widget.collection.background != null) { - return Hero( - tag: 'collection-${widget.collection.id}', - child: Image.network( - '${AppEngine.engine.pb.baseURL}/api/files/${widget.collection.collectionId}/${widget.collection.id}/${widget.collection.background}', - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => - _buildFallbackBackground(), - ), - ); - } - return _buildFallbackBackground(); - } - - Widget _buildFallbackBackground() { - // Generate a unique but consistent color based on collection ID - final color = Color( - (widget.collection.id.hashCode & 0xFFFFFF) | 0xFF000000, - ); - - return Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - color, - Color.lerp(color, Colors.white, 0.2)!, - ], - ), - ), - child: const Icon( - Icons.collections, - size: 80, - color: Colors.white24, - ), - ); - } - - Widget _buildMetadataChip(IconData icon, String label) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: Colors.black26, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 16, color: Colors.white70), - const SizedBox(width: 4), - Text( - label, - style: const TextStyle(color: Colors.white70), - ), - ], - ), - ); - } - - Widget _buildInteractionButton() { - return Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - ), - child: IconButton( - icon: const Icon(Icons.arrow_forward, color: Colors.black87), - visualDensity: VisualDensity.compact, - onPressed: () { - // Handle interaction - }, - ), - ); - } - - String _formatDate(DateTime date) { - return '${date.day}/${date.month}/${date.year}'; - } -} - -// Custom Pattern Painter for subtle overlay -class PatternPainter extends CustomPainter { - @override - void paint(Canvas canvas, Size size) { - final paint = Paint() - ..color = Colors.white.withOpacity(0.05) - ..strokeWidth = 1; - - for (var i = 0; i < size.width; i += 20) { - for (var j = 0; j < size.height; j += 20) { - canvas.drawCircle(Offset(i.toDouble(), j.toDouble()), 1, paint); - } - } - } - - @override - bool shouldRepaint(CustomPainter oldDelegate) => false; -} diff --git a/lib/features/common/utils/error_handler.dart b/lib/features/common/utils/error_handler.dart new file mode 100644 index 0000000..7a60492 --- /dev/null +++ b/lib/features/common/utils/error_handler.dart @@ -0,0 +1,71 @@ +import 'package:pocketbase/pocketbase.dart'; + +String getErrorMessage(ClientException e) { + switch (e.statusCode) { + case 400: + final data = e.response['data']; + + if (data?['email'] != null) { + switch (data['email']['code']) { + case 'validation_invalid_email': + return 'Please enter a valid email address'; + case 'validation_not_unique': + return 'This email is already registered'; + default: + return 'Invalid email'; + } + } + + if (data?['password'] != null) { + switch (data['password']['code']) { + case 'validation_length_out_of_range': + return 'Password must be at least 6 characters long'; + case 'validation_too_weak': + return 'Password is too weak. Please include numbers and special characters'; + default: + return 'Invalid password'; + } + } + + if (data?['passwordConfirm'] != null) { + switch (data['passwordConfirm']['code']) { + case 'validation_values_mismatch': + return 'Passwords do not match'; + default: + return 'Password confirmation error'; + } + } + + if (data?['username'] != null || data?['name'] != null) { + return 'Please enter a valid name'; + } + + return 'Please check your input and try again'; + + case 401: + return 'Invalid credentials'; + + case 403: + return 'You don\'t have permission to perform this action'; + + case 404: + return 'Resource not found'; + + case 429: + return 'Too many attempts. Please try again later'; + + case 500: + return 'Server error. Please try again later'; + + case 503: + return 'Service temporarily unavailable. Please try again later'; + + default: + final message = e.response['message']; + if (message != null && message.toString().isNotEmpty) { + return message.toString(); + } + + return 'An error occurred. Please try again'; + } +} diff --git a/lib/utils/auth_refresh.dart b/lib/features/common/utils/refresh_auth.dart similarity index 53% rename from lib/utils/auth_refresh.dart rename to lib/features/common/utils/refresh_auth.dart index 8af0685..5766be9 100644 --- a/lib/utils/auth_refresh.dart +++ b/lib/features/common/utils/refresh_auth.dart @@ -1,11 +1,11 @@ -import '../engine/engine.dart'; +import 'package:madari_client/features/pocketbase/service/pocketbase.service.dart'; Future refreshAuth() async { - final pb = AppEngine.engine.pb; + final pb = AppPocketBaseService.instance.pb; final userCollection = pb.collection("users"); final user = await userCollection.getOne( - AppEngine.engine.pb.authStore.record!.id, + pb.authStore.record!.id, ); pb.authStore.save(pb.authStore.token, user); } diff --git a/lib/features/common/utils/startup_app.dart b/lib/features/common/utils/startup_app.dart new file mode 100644 index 0000000..c25d2c7 --- /dev/null +++ b/lib/features/common/utils/startup_app.dart @@ -0,0 +1,42 @@ +import 'package:cached_query_flutter/cached_query_flutter.dart'; +import 'package:cached_storage/cached_storage.dart'; +import 'package:flutter/foundation.dart'; +import 'package:logging/logging.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:universal_platform/universal_platform.dart'; +import 'package:window_manager/window_manager.dart'; + +import '../../pocketbase/service/pocketbase.service.dart'; +import '../../widgetter/plugin_base.dart'; +import '../../widgetter/plugins/stremio/stremio_plugin.dart'; + +final _logger = Logger('StartupApp'); + +Future startupApp() async { + MediaKit.ensureInitialized(); + + await AppPocketBaseService.ensureInitialized(); + + if (UniversalPlatform.isDesktop) { + await windowManager.ensureInitialized(); + } + + if (kDebugMode) { + PluginRegistry.instance.reset(); + } + PluginRegistry.instance.registerPlugin( + StremioCatalogPlugin(), + ); + + try { + CachedQuery.instance.configFlutter( + storage: await CachedStorage.ensureInitialized(), + config: QueryConfigFlutter( + refetchDuration: const Duration(minutes: 60), + cacheDuration: const Duration(minutes: 60), + ), + ); + } catch (e) { + _logger.warning("Unable initialize cache"); + } +} diff --git a/lib/features/connection/containers/auto_import.dart b/lib/features/connection/containers/auto_import.dart deleted file mode 100644 index 9156fc3..0000000 --- a/lib/features/connection/containers/auto_import.dart +++ /dev/null @@ -1,201 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:madari_client/engine/library.dart'; -import 'package:madari_client/features/connection/services/stremio_service.dart'; - -import '../../../engine/engine.dart'; -import '../../settings/types/connection.dart'; - -class AutoImport extends StatefulWidget { - final Connection item; - final VoidCallback? onImport; - - const AutoImport({ - super.key, - required this.item, - this.onImport, - }); - - @override - State createState() => _AutoImportState(); -} - -class _AutoImportState extends State { - late StremioService _stremio; - final List _selected = []; - bool _isLoading = false; - bool _selectAll = false; - - Future>? _folders; - - @override - void initState() { - super.initState(); - initialValueImport(); - } - - void initialValueImport() { - if ("stremio_addons" == widget.item.type) { - _stremio = StremioService( - connectionId: Future.delayed( - Duration.zero, - () => widget.item.id, - ), - config: widget.item.config!, - ); - - _folders = _stremio.getFolders(); - } - } - - void createLibraryInBulk() async { - setState(() { - _isLoading = true; - }); - - int loaded = 0; - for (var item in _selected) { - try { - await AppEngine.engine.pb.collection("library").create(body: { - "title": item.title, - "icon": Icons.video_library.codePoint.toString(), - "types": ["video"], - "user": AppEngine.engine.pb.authStore.record!.id, - "config": [item.config ?? item.id], - "connection": widget.item.id, - }); - - loaded += 1; - } catch (e, stack) { - if (kDebugMode) print("Failed to $e"); - if (kDebugMode) print(stack); - } - } - - if (context.mounted && mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - "Imported Libraries $loaded failed ${_selected.length - loaded}", - ), - ), - ); - - setState(() { - _isLoading = false; - }); - - if (widget.onImport != null) widget.onImport!(); - } - } - - void toggleSelectAll() async { - final folders = await _folders; - if (folders == null) return; - - setState(() { - _selectAll = !_selectAll; - if (_selectAll) { - _selected.clear(); - _selected.addAll(folders); - } else { - _selected.clear(); - } - }); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.transparent, - appBar: AppBar( - title: const Text("Import Libraries"), - backgroundColor: Colors.transparent, - actions: [ - IconButton( - icon: Icon(_selectAll - ? Icons.check_box_outlined - : Icons.check_box_outline_blank), - onPressed: () { - toggleSelectAll(); - }, - ), - const SizedBox( - width: 12, - ), - ElevatedButton.icon( - onPressed: _selected.isNotEmpty - ? () { - createLibraryInBulk(); - } - : null, - label: const Text("Import"), - icon: _isLoading - ? const SizedBox( - width: 12, - height: 12, - child: CircularProgressIndicator(), - ) - : const Icon(Icons.save), - ), - const SizedBox( - width: 6, - ), - ], - ), - body: FutureBuilder( - future: _folders, - builder: (context, snapshot) { - if (snapshot.hasError) { - return Text("Error: ${snapshot.error}"); - } - - if (!snapshot.hasData) { - return const Center( - child: CircularProgressIndicator(), - ); - } - - final folders = snapshot.data!; - - return ListView.builder( - itemCount: folders.length, - itemBuilder: (context, index) { - final item = folders[index]; - - final isSelected = - _selected.any((selected) => selected.id == item.id); - - return ListTile( - onTap: () { - setState(() { - if (isSelected) { - _selected - .removeWhere((selected) => selected.id == item.id); - } else { - _selected.add(item); - } - }); - }, - leading: isSelected - ? const Icon(Icons.check) - : const Icon(Icons.check_box_outline_blank), - title: Text(item.title), - ); - }, - ); - }, - ), - ); - } -} - -class AutoImportData { - final String id; - final String title; - - AutoImportData({ - required this.id, - required this.title, - }); -} diff --git a/lib/features/connection/containers/configure_neo_connection.dart b/lib/features/connection/containers/configure_neo_connection.dart deleted file mode 100644 index bfc01a3..0000000 --- a/lib/features/connection/containers/configure_neo_connection.dart +++ /dev/null @@ -1,141 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:madari_client/engine/connection_type.dart'; - -class ConfigureNeoConnection extends StatefulWidget { - final ConnectionTypeRecord item; - final void Function(String id) onConnectionComplete; - - const ConfigureNeoConnection({ - super.key, - required this.item, - required this.onConnectionComplete, - }); - - @override - State createState() => _ConfigureNeoConnectionState(); -} - -class _ConfigureNeoConnectionState extends State { - final _formKey = GlobalKey(); - final _urlController = TextEditingController(); - final _usernameController = TextEditingController(); - final _passwordController = TextEditingController(); - bool _isLoading = false; - - @override - void dispose() { - _urlController.dispose(); - _usernameController.dispose(); - _passwordController.dispose(); - super.dispose(); - } - - Future _saveConnection() async { - if (!_formKey.currentState!.validate()) { - return; - } - - setState(() => _isLoading = true); - - try { - if (!mounted) return; - - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Connection saved successfully'), - backgroundColor: Colors.green, - ), - ); - - // widget.onConnectionComplete(""); - } catch (e) { - if (!mounted) return; - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Error saving connection: ${e.toString()}'), - backgroundColor: Colors.red, - ), - ); - } finally { - if (mounted) { - setState(() => _isLoading = false); - } - } - } - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(16.0), - child: Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - TextFormField( - controller: _urlController, - autofocus: true, - decoration: const InputDecoration( - labelText: 'Server URL', - prefixIcon: Icon(Icons.sensors_rounded), - hintText: 'https://neo.example.com', - border: OutlineInputBorder(), - ), - keyboardType: TextInputType.url, - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter server URL'; - } - if (!(Uri.tryParse(value)?.hasAuthority ?? true)) { - return 'Please enter a valid URL'; - } - return null; - }, - ), - const SizedBox(height: 16), - TextFormField( - controller: _usernameController, - decoration: const InputDecoration( - labelText: 'Email', - border: OutlineInputBorder(), - ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter email'; - } - return null; - }, - ), - const SizedBox(height: 16), - TextFormField( - controller: _passwordController, - decoration: const InputDecoration( - labelText: 'Password', - border: OutlineInputBorder(), - ), - obscureText: true, - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter password'; - } - return null; - }, - ), - const SizedBox(height: 24), - ElevatedButton( - onPressed: _isLoading ? null : _saveConnection, - child: _isLoading - ? const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Text('Save Connection'), - ), - ], - ), - ), - ); - } -} diff --git a/lib/features/connection/containers/configure_stremio_connection.dart b/lib/features/connection/containers/configure_stremio_connection.dart deleted file mode 100644 index 2ca874a..0000000 --- a/lib/features/connection/containers/configure_stremio_connection.dart +++ /dev/null @@ -1,306 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:http/http.dart' as http; -import 'package:madari_client/engine/connection_type.dart'; -import 'package:pocketbase/pocketbase.dart'; - -import '../../../engine/engine.dart'; -import '../../settings/types/connection.dart'; - -class StremioAddonConnection extends StatefulWidget { - final void Function(Connection connection) onConnectionComplete; - final ConnectionTypeRecord item; - - const StremioAddonConnection({ - super.key, - required this.onConnectionComplete, - required this.item, - }); - - @override - State createState() => _StremioAddonConnectionState(); -} - -class _StremioAddonConnectionState extends State { - final PocketBase pb = AppEngine.engine.pb; - final _formKey = GlobalKey(); - final _urlController = TextEditingController(); - final _nameController = TextEditingController(text: "Stremio"); - - static const String cinemetaURL = - 'https://v3-cinemeta.strem.io/manifest.json'; - - bool _isLoading = false; - String? _errorMessage; - - final List> _addons = []; - - Future _validateAddonUrl(String url) async { - setState(() { - _isLoading = true; - _errorMessage = null; - }); - - try { - final response = await http.get(Uri.parse(url)); - if (response.statusCode == 200) { - final manifest = json.decode(response.body); - - if (manifest['name'] == null || manifest['id'] == null) { - throw 'Invalid addon manifest'; - } - - if (_addons.any((addon) => addon['url'] == url)) { - throw 'Addon already added to the list'; - } - - setState(() { - _addons.add({ - 'name': manifest['name'], - 'icon': manifest['logo'] ?? manifest['icon'], - 'url': url, - }); - _urlController.clear(); - }); - } else { - throw 'Failed to fetch addon manifest'; - } - } catch (e) { - if (e is FormatException) { - setState(() { - _errorMessage = 'Invalid addon URL'; - }); - } else { - setState(() { - _errorMessage = 'Invalid addon URL: ${e.toString()}'; - }); - } - } finally { - setState(() { - _isLoading = false; - }); - } - } - - Future _saveAddons() async { - if (!_formKey.currentState!.validate() || _addons.isEmpty) return; - - try { - setState(() => _isLoading = true); - - final body = await pb.collection('connection').create(body: { - 'title': _nameController.text, - 'user': pb.authStore.record!.id, - 'type': widget.item.id, - 'config': { - 'addons': _addons - .map( - (item) => item["url"], - ) - .toList() - }, - }); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text("Saved successfully"), - ), - ); - } - - widget.onConnectionComplete( - Connection( - title: body.getStringValue("title"), - id: body.id, - config: jsonEncode({ - 'addons': _addons - .map( - (item) => item["url"], - ) - .toList() - }), - type: "stremio_addons", - ), - ); - } catch (e) { - if (e is ClientException) { - final response = e.response["data"]; - - final result = response.values.map((item) => item["message"]).join(" "); - - setState(() { - if (kDebugMode) print(result); - _errorMessage = "Error: $result"; - }); - - return; - } - - setState(() { - _errorMessage = "Error: ${e.toString()}"; - }); - } finally { - setState(() => _isLoading = false); - } - } - - void _removeAddon(int index) { - setState(() { - _addons.removeAt(index); - }); - } - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only( - left: 16.0, - right: 16.0, - bottom: 16.0, - ), - child: Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - TextFormField( - controller: _nameController, - decoration: const InputDecoration( - labelText: 'Connection name', - ), - validator: (value) { - if (value != null && value.isNotEmpty) { - return null; - } - return "Connection name is required"; - }, - ), - const SizedBox( - height: 12, - ), - TextFormField( - controller: _urlController, - decoration: InputDecoration( - labelText: 'Addon URL', - hintText: 'https://example.com/manifest.json', - suffixIcon: IconButton( - icon: const Icon(Icons.add), - onPressed: () => _validateAddonUrl(_urlController.text), - ), - ), - validator: (value) { - if (_addons.isEmpty) { - return 'Please add at least one addon'; - } - if (value != null && value.isNotEmpty) { - try { - final uri = Uri.parse(value); - if (!uri.isScheme('http') && !uri.isScheme('https')) { - return 'Please enter a valid HTTP/HTTPS URL'; - } - } catch (e) { - return 'Please enter a valid URL'; - } - } - return null; - }, - ), - if (_isLoading) const Center(child: CircularProgressIndicator()), - if (_errorMessage != null) - Padding( - padding: const EdgeInsets.only( - top: 8.0, - ), - child: Text( - _errorMessage!, - style: const TextStyle(color: Colors.red), - ), - ), - const SizedBox( - height: 12, - ), - Text( - 'Suggested Addons:', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox( - height: 6, - ), - Row( - children: [ - ActionChip.elevated( - label: const Text("Cinemeta"), - onPressed: () { - _validateAddonUrl(cinemetaURL); - }, - avatar: const Icon(Icons.extension), - ) - ], - ), - if (_addons.isNotEmpty) ...[ - const SizedBox(height: 16), - Text( - 'Added Addons:', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox( - height: 6, - ), - ListView.builder( - shrinkWrap: true, - itemCount: _addons.length, - itemBuilder: (context, index) { - final addon = _addons[index]; - return Container( - padding: const EdgeInsets.only(bottom: 12), - child: Card( - margin: EdgeInsets.zero, - child: ListTile( - leading: addon['icon'] != null - ? Image.network( - addon['icon'], - width: 40, - height: 40, - errorBuilder: (_, __, ___) => - const Icon(Icons.extension), - ) - : const Icon( - Icons.extension, - size: 40, - ), - title: Text(addon['name']), - subtitle: Text( - addon['url'], - maxLines: 1, - ), - trailing: IconButton( - icon: const Icon(Icons.remove_circle_outline), - onPressed: () => _removeAddon(index), - color: Colors.red, - ), - ), - ), - ); - }, - ), - ], - const SizedBox(height: 16), - ElevatedButton( - onPressed: _addons.isNotEmpty && !_isLoading ? _saveAddons : null, - child: const Text('Save Configuration'), - ), - ], - ), - ), - ); - } - - @override - void dispose() { - _urlController.dispose(); - super.dispose(); - } -} diff --git a/lib/features/connection/containers/connection_manager.dart b/lib/features/connection/containers/connection_manager.dart deleted file mode 100644 index 2980870..0000000 --- a/lib/features/connection/containers/connection_manager.dart +++ /dev/null @@ -1,265 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:madari_client/features/connection/containers/auto_import.dart'; -import 'package:madari_client/features/settings/types/connection.dart'; -import 'package:pocketbase/pocketbase.dart'; - -import '../../../engine/engine.dart'; -import '../../../engine/library.dart'; -import '../../library/screen/create_new_library.dart'; - -class ConnectionManager extends StatefulWidget { - final Connection item; - - const ConnectionManager({ - super.key, - required this.item, - }); - - @override - State createState() => _ConnectionManagerState(); -} - -class _ConnectionManagerState extends State { - final PocketBase pb = AppEngine.engine.pb; - late Future> _items; - bool _isLoading = false; - bool _isDragging = false; - - @override - void initState() { - super.initState(); - _refreshItems(); - } - - void _refreshItems() { - setState(() { - _items = pb.collection("library").getList( - filter: "connection.id = ${jsonEncode(widget.item.id)}", - sort: "+order", - ); - }); - } - - Future _updateOrder( - int oldIndex, int newIndex, List items) async { - setState(() => _isDragging = true); - try { - if (oldIndex < newIndex) { - newIndex -= 1; - } - - final item = items.removeAt(oldIndex); - items.insert(newIndex, item); - - // Update order for all affected items - for (int i = 0; i < items.length; i++) { - await pb.collection("library").update( - items[i].id, - body: {"order": i}, - ); - } - } finally { - setState(() => _isDragging = false); - _refreshItems(); - } - } - - Future _showEditDialog(RecordModel item) async { - final TextEditingController titleController = TextEditingController( - text: item.getStringValue("title"), - ); - - return showDialog( - context: context, - barrierDismissible: !_isLoading, - builder: (context) => AlertDialog( - title: const Text('Edit Library'), - content: TextField( - controller: titleController, - decoration: const InputDecoration(labelText: 'Library Name'), - autofocus: true, - enabled: !_isLoading, - ), - actions: [ - TextButton( - onPressed: _isLoading ? null : () => Navigator.pop(context), - child: const Text('Cancel'), - ), - TextButton( - onPressed: _isLoading - ? null - : () async { - setState(() => _isLoading = true); - try { - await pb.collection("library").update( - item.id, - body: {"title": titleController.text}, - ); - if (context.mounted) if (mounted) Navigator.pop(context); - _refreshItems(); - } finally { - setState(() => _isLoading = false); - } - }, - child: _isLoading - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Text('Save'), - ), - ], - ), - ); - } - - Future _confirmDelete(RecordModel item) async { - return showDialog( - context: context, - barrierDismissible: !_isLoading, - builder: (context) => AlertDialog( - title: const Text('Confirm Delete'), - content: const Text('Are you sure you want to delete this library?'), - actions: [ - TextButton( - onPressed: _isLoading ? null : () => Navigator.pop(context), - child: const Text('Cancel'), - ), - TextButton( - onPressed: _isLoading - ? null - : () async { - setState(() => _isLoading = true); - try { - await pb.collection("library").delete(item.id); - if (mounted && context.mounted) Navigator.pop(context); - _refreshItems(); - } finally { - setState(() => _isLoading = false); - } - }, - child: _isLoading - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Text('Delete', style: TextStyle(color: Colors.red)), - ), - ], - ), - ); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text("Connection ${widget.item.title}"), - actions: [ - const SizedBox(width: 6), - ElevatedButton.icon( - onPressed: _isDragging || _isLoading - ? null - : () { - showModalBottomSheet( - context: context, - builder: (ctx) => AutoImport(item: widget.item), - ).then((_) => _refreshItems()); - }, - label: const Text("Auto Import"), - icon: const Icon(Icons.auto_awesome), - ), - const SizedBox(width: 10), - ], - ), - floatingActionButton: FloatingActionButton.extended( - onPressed: _isDragging || _isLoading - ? null - : () { - showModalBottomSheet( - context: context, - builder: (context) => Consumer( - builder: (context, ref, child) => CreateNewLibrary( - item: widget.item, - onCreatedAnother: () {}, - onCreated: () { - Navigator.pop(context); - ref.refresh(libraryListProvider(1).future); - _refreshItems(); - }, - ), - ), - ); - }, - label: const Text("Add new library"), - icon: const Icon(Icons.add), - ), - body: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 800), - child: FutureBuilder( - future: _items, - builder: (ctx, result) { - if (result.hasError) { - return Center( - child: Text("Error: ${result.error}"), - ); - } - - if (!result.hasData) { - return const Center( - child: CircularProgressIndicator(), - ); - } - - final items = result.data!.items; - - return ReorderableListView.builder( - padding: const EdgeInsets.all(8.0), - itemCount: items.length, - onReorder: (oldIndex, newIndex) => - _updateOrder(oldIndex, newIndex, items), - itemBuilder: (context, index) { - final item = items[index]; - return Card( - key: ValueKey(item.id), - margin: - const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - child: ListTile( - title: Text( - item.getStringValue("title"), - style: Theme.of(context).textTheme.titleMedium, - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.edit), - onPressed: _isDragging || _isLoading - ? null - : () => _showEditDialog(item), - ), - IconButton( - icon: const Icon(Icons.delete), - onPressed: _isDragging || _isLoading - ? null - : () => _confirmDelete(item), - ), - ], - ), - ), - ); - }, - ); - }, - ), - ), - ), - ); - } -} diff --git a/lib/features/connection/containers/create_new_connection.dart b/lib/features/connection/containers/create_new_connection.dart deleted file mode 100644 index 5f555cf..0000000 --- a/lib/features/connection/containers/create_new_connection.dart +++ /dev/null @@ -1,208 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:madari_client/engine/connection_type.dart'; -import 'package:pocketbase/pocketbase.dart'; - -class CreateNewConnection extends StatefulWidget { - final void Function(ConnectionTypeRecord record)? onCallback; - - const CreateNewConnection({ - super.key, - this.onCallback, - }); - - @override - State createState() => _CreateNewConnectionState(); -} - -class _CreateNewConnectionState extends State { - @override - Widget build(BuildContext context) { - return Container( - constraints: BoxConstraints( - maxHeight: MediaQuery.of(context).size.height * 0.85, - minHeight: MediaQuery.of(context).size.height * 0.5, - ), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: const BorderRadius.vertical(top: Radius.circular(28)), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _buildBottomSheetHandle(), - _buildHeader(context), - Expanded( - child: Consumer( - builder: (context, ref, child) { - final activity = ref.watch(connectionTypeListProvider(page: 1)); - - return activity.when( - data: (result) => _buildConnectionList(result), - error: (error, trace) => _buildError(error), - loading: () => const Center( - child: CircularProgressIndicator(), - ), - ); - }, - ), - ), - ], - ), - ); - } - - Widget _buildBottomSheetHandle() { - return Padding( - padding: const EdgeInsets.only(top: 12, bottom: 8), - child: Container( - width: 32, - height: 4, - decoration: BoxDecoration( - color: Colors.grey[300], - borderRadius: BorderRadius.circular(2), - ), - ), - ); - } - - Widget _buildHeader(BuildContext context) { - return Container( - padding: const EdgeInsets.fromLTRB(24, 8, 24, 16), - child: Column( - children: [ - Text( - "Add New Connection", - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - "Select a connection type to configure", - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Colors.grey[600], - ), - ), - ], - ), - ); - } - - Widget _buildConnectionList(ResultList result) { - return ListView.builder( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - itemCount: result.items.length, - itemBuilder: (context, index) => _buildConnectionTile( - context, - result.items[index], - ), - ); - } - - Widget _buildConnectionTile(BuildContext context, ConnectionTypeRecord item) { - return InkWell( - onTap: () { - final callback = widget.onCallback; - - if (callback != null) { - callback(item); - } - }, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - border: Border.all( - color: Colors.grey[200]!, - width: 1, - ), - borderRadius: BorderRadius.circular(12), - ), - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .primaryContainer - .withOpacity(0.2), - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - getIcon(item.icon), - color: Theme.of(context).colorScheme.primary, - size: 24, - ), - ), - const SizedBox(width: 16), - Expanded( - child: Text( - item.title, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w500, - ), - ), - ), - Icon( - Icons.chevron_right, - color: Colors.grey[400], - size: 20, - ), - ], - ), - ), - ), - ), - ); - } - - Widget _buildError(Object error) { - return Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.error_outline, - size: 48, - color: Colors.red[300], - ), - const SizedBox(height: 16), - Text( - 'Unable to load connection types', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.grey[800], - ), - ), - const SizedBox(height: 8), - Text( - 'Please try again later', - style: TextStyle(color: Colors.grey[600]), - ), - ], - ), - ); - } - - IconData getIcon(String input) { - switch (input) { - case "drive_file_move": - return Icons.drive_file_move; - case "sensors_rounded": - return Icons.sensors_rounded; - case "telegram": - return Icons.telegram; - case "video": - return Icons.stream; - default: - return Icons.ac_unit; - } - } -} diff --git a/lib/features/connection/containers/folder_selector.dart b/lib/features/connection/containers/folder_selector.dart deleted file mode 100644 index 0cbe65e..0000000 --- a/lib/features/connection/containers/folder_selector.dart +++ /dev/null @@ -1,170 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:madari_client/features/connection/services/stremio_service.dart'; -import 'package:madari_client/features/settings/types/connection.dart'; - -import '../../../engine/library.dart'; -import '../services/base_connection_service.dart'; - -class FolderSelector extends StatefulWidget { - final void Function(List) onFolderSelected; - final Connection item; - - const FolderSelector({ - super.key, - required this.onFolderSelected, - required this.item, - }); - - @override - createState() => _FolderSelectorState(); -} - -class _FolderSelectorState extends State { - List _folders = []; - final List _selectedFolder = []; - bool _isLoading = true; - String? _errorMessage; - late final BaseConnectionService connectionService; - - final TextEditingController _searchController = TextEditingController(); - List _filteredFolders = []; - - @override - void initState() { - super.initState(); - - final connectionId = Future.delayed( - Duration.zero, - () => widget.item.id, - ); - - switch (widget.item.type) { - case "stremio_addons": - connectionService = StremioService( - connectionId: connectionId, - config: widget.item.config ?? "{}", - ); - - default: - throw TypeError(); - } - - _loadFolders(); - _searchController.addListener(_filterFolders); - } - - void _filterFolders() { - final query = _searchController.text.toLowerCase(); - setState(() { - _filteredFolders = _folders.where((folder) { - return folder.title.toLowerCase().contains(query); - }).toList(); - }); - } - - @override - void dispose() { - _searchController.dispose(); - super.dispose(); - } - - Future _loadFolders() async { - setState(() { - _isLoading = true; - _errorMessage = null; - }); - - try { - final folders = await _fetchFolders(); - - setState(() { - _folders = folders; - _filteredFolders = _folders; - _isLoading = false; - }); - - _filterFolders(); - } catch (e) { - setState(() { - _errorMessage = 'Failed to load folders'; - _isLoading = false; - }); - rethrow; - } - } - - Future> _fetchFolders() async { - return connectionService.getFolders(); - } - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Select', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 10), - TextField( - controller: _searchController, - decoration: InputDecoration( - hintText: 'Search folders...', - prefixIcon: const Icon(Icons.search), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - contentPadding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - ), - ), - const SizedBox(height: 16), - if (_isLoading) - const Center(child: CircularProgressIndicator()) - else if (_errorMessage != null) - Text( - _errorMessage!, - style: const TextStyle(color: Colors.red), - ) - else if (_filteredFolders.isEmpty) - const Text('No folders available') - else - ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: _filteredFolders.length, - itemBuilder: (context, index) { - final folder = _filteredFolders[index]; - - final selected = - _selectedFolder.where((i) => i.id == folder.id).isNotEmpty; - - return ListTile( - title: Text(folder.title), - leading: folder.icon, - trailing: selected - ? Icon( - Icons.check, - color: Theme.of(context).primaryColorLight, - ) - : const Icon(Icons.circle_outlined), - selected: selected, - selectedTileColor: Colors.blue.withOpacity(0.1), - onTap: () { - setState(() { - if (!selected) { - _selectedFolder.add(folder); - } else { - _selectedFolder.remove(folder); - } - widget.onFolderSelected(_selectedFolder); - }); - }, - ); - }, - ), - ], - ); - } -} diff --git a/lib/features/connection/containers/show_handle_connection_type.dart b/lib/features/connection/containers/show_handle_connection_type.dart deleted file mode 100644 index 01b74c8..0000000 --- a/lib/features/connection/containers/show_handle_connection_type.dart +++ /dev/null @@ -1,94 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:madari_client/features/settings/types/connection.dart'; - -import '../../../engine/connection_type.dart'; -import 'configure_stremio_connection.dart'; - -class ShowHandleConnectionType extends StatelessWidget { - final ConnectionTypeRecord item; - final void Function(Connection id) onFinish; - - const ShowHandleConnectionType({ - super.key, - required this.item, - required this.onFinish, - }); - - @override - Widget build(BuildContext context) { - return Container( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, - ), - constraints: BoxConstraints( - // Remove fixed height constraints to allow content to resize - maxHeight: MediaQuery.of(context).size.height * .9, - minWidth: double.infinity, - ), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: const BorderRadius.vertical(top: Radius.circular(28)), - ), - child: SafeArea( - child: SingleChildScrollView( - child: _build(context), - ), - ), - ); - } - - _build(BuildContext context) { - Widget child = Container(); - - switch (item.type) { - case "stremio_addons": - child = StremioAddonConnection( - item: item, - onConnectionComplete: (id) { - onFinish(id); - }, - ); - break; - } - - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - _buildBottomSheetHandle(), - _buildHeader(context), - child, - ], - ); - } - - Widget _buildBottomSheetHandle() { - return Padding( - padding: const EdgeInsets.only(top: 12, bottom: 8), - child: Container( - width: 32, - height: 4, - decoration: BoxDecoration( - color: Colors.grey[300], - borderRadius: BorderRadius.circular(2), - ), - ), - ); - } - - Widget _buildHeader(BuildContext context) { - return Container( - padding: const EdgeInsets.fromLTRB(24, 8, 24, 16), - child: Column( - children: [ - Text( - "Configure connection", - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - ], - ), - ); - } -} diff --git a/lib/features/connection/services/base_connection_service.dart b/lib/features/connection/services/base_connection_service.dart deleted file mode 100644 index 50691bd..0000000 --- a/lib/features/connection/services/base_connection_service.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:madari_client/engine/engine.dart'; -import 'package:madari_client/features/doc_viewer/types/doc_source.dart'; -import 'package:pocketbase/pocketbase.dart'; - -import '../../../engine/library.dart'; - -abstract class BaseConnectionService { - Future> getFolders(); - Future> getList({ - int page = 1, - required String config, - List? lastItem, - required List type, - String? search, - }); - abstract Future connectionId; - - Future createLibrary({ - required String title, - required String icon, - required List types, - required String config, - }) async { - AppEngine.engine.pb.collection("library").create( - body: { - "title": title, - "icon": icon, - "types": types, - "user": AppEngine.engine.pb.authStore.record?.id, - "config": config, - "connection": connectionId, - }, - ); - } - - Stream> getItem(LibraryItemList item); -} diff --git a/lib/features/connection/services/stremio_service.dart b/lib/features/connection/services/stremio_service.dart deleted file mode 100644 index 01bf4e1..0000000 --- a/lib/features/connection/services/stremio_service.dart +++ /dev/null @@ -1,266 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:http/http.dart' as http; -import 'package:json_annotation/json_annotation.dart'; -import 'package:madari_client/engine/library.dart'; -import 'package:madari_client/features/connection/services/base_connection_service.dart'; -import 'package:madari_client/features/connection/types/stremio.dart'; -import 'package:madari_client/features/doc_viewer/types/doc_source.dart'; -import 'package:pocketbase/pocketbase.dart'; - -import '../../../engine/engine.dart'; - -part 'stremio_service.g.dart'; - -class StremioService extends BaseConnectionService { - @override - Future connectionId; - String config; - - static final Map _cache = {}; - - late StremioConfig configParsed; - - StremioService({ - required this.connectionId, - required this.config, - }) { - configParsed = StremioConfig.fromJson(jsonDecode(config)); - - connectionId.then((item) { - AppEngine.engine.pb.collection("connection").getOne(item).then((docs) { - configParsed = StremioConfig.fromJson(docs.get("config")); - }); - }); - } - - Future getManifest(String url) async { - if (_cache.containsKey(url)) { - return _cache[url]!; - } - - final result = await http.get(Uri.parse(url)); - final resultFinal = StremioManifest.fromJson(jsonDecode(result.body)); - _cache[url] = resultFinal; - - return resultFinal; - } - - Future getItemMetaById(String type, String id) async { - for (final addon in configParsed.addons) { - final manifest = await getManifest(addon); - - if (manifest.resources?.contains("meta") != true) { - if (kDebugMode) print("ignoring because meta is not there"); - continue; - } - - final ids = manifest.idPrefixes - ?.firstWhere((item) => id.startsWith(item), orElse: () => ""); - - if (ids == null) { - continue; - } - - final result = await http.get( - Uri.parse("${_getAddonBaseURL(addon)}/meta/$type/$id.json"), - ); - - return StreamMetaResponse.fromJson(jsonDecode(result.body)).meta; - } - - return null; - } - - @override - Future> getFolders() async { - final List result = []; - - for (final addon in configParsed.addons) { - final manifest = await getManifest(addon); - - final List resources = (manifest.resources ?? []).map( - (item) { - return item.name; - }, - ).toList(); - - if (resources.contains("catalog") || - manifest.catalogs?.isNotEmpty == true) { - for (final item - in (manifest.catalogs ?? [] as List)) { - result.add( - FolderItem( - title: item.name == null - ? "${manifest.name} - ${item.type.capitalize()}".trim() - : "${item.type.capitalize()} - ${item.name}", - id: "${item.type}-${item.id}", - icon: const Icon(Icons.movie), - config: jsonEncode( - { - "type": item.type, - "id": "${item.type}-${item.id}", - "title": - "${item.type} ${item.name?.trim() != "" ? item.name : ""}" - .trim(), - 'addon': addon, - 'item': item, - }, - ), - ), - ); - } - } - } - - return result; - } - - @override - Stream> getItem(LibraryItemList item) { - throw UnimplementedError(); - } - - @override - Future> getList({ - int page = 1, - required String config, - List? lastItem, - required List type, - String? search, - }) async { - final configOutput = jsonDecode(config); - - final List items = []; - - for (final item in configOutput) { - final itemToPush = InternalManifestItemConfig.fromJson(item); - items.add(itemToPush); - } - - final result = ResultList(); - result.page = page; - result.perPage = 50; - result.items = List.empty(growable: true); - - for (final item in items) { - String url = - "${_getAddonBaseURL(item.addon)}/catalog/${item.item.type}/${item.item.id}.json"; - - if (page != 1) { - final skip = result.perPage * (page - 1); - - url = - "${_getAddonBaseURL(item.addon)}/catalog/${item.item.type}/${item.item.id}/skip=${Uri.encodeComponent(skip.toString())}.json"; - } - - if ((search ?? "").isNotEmpty) { - url = - "${_getAddonBaseURL(item.addon)}/catalog/${item.item.type}/${item.item.id}/search=${Uri.encodeComponent(search!)}.json"; - } - - final httpBody = await http.get( - Uri.parse( - url, - ), - ); - - final meta = StrmioMeta.fromJson(json.decode(httpBody.body)); - - for (final meta in meta.metas ?? []) { - result.items.add( - LibraryItemList( - id: meta.id, - title: meta.name!, - logo: meta.poster, - extra: meta.description, - config: jsonEncode(meta), - popularity: (meta.popularity ?? 0), - ), - ); - } - } - - return result; - } - - _getAddonBaseURL(String input) { - return input.endsWith("/manifest.json") - ? input.replaceAll("/manifest.json", "") - : input; - } - - Stream> getStreams( - String type, - String id, { - String? season, - String? episode, - }) async* { - final List streams = []; - - for (final addon in configParsed.addons) { - final addonManifest = await getManifest(addon); - - for (final resource in (addonManifest.resources ?? [])) { - if ((resource is String && resource == "stream") || - ((resource is ResourceObject) && - resource.types?.contains(type) == true)) { - final url = - "${_getAddonBaseURL(addon)}/stream/$type/${Uri.encodeComponent(id)}.json"; - - final result = await http.get(Uri.parse(url), headers: {}); - - final body = StreamResponse.fromJson(jsonDecode(result.body)); - - streams.addAll(body.streams); - - yield streams; - } - } - } - - return; - } -} - -extension StringExtension on String { - String capitalize() { - return "${this[0].toUpperCase()}${substring(1).toLowerCase()}"; - } -} - -@JsonSerializable() -class InternalManifestItemConfig { - final InternalItem item; - final String addon; - - InternalManifestItemConfig({ - required this.item, - required this.addon, - }); - - factory InternalManifestItemConfig.fromJson(Map json) => - _$InternalManifestItemConfigFromJson(json); - - Map toJson() => _$InternalManifestItemConfigToJson(this); -} - -@JsonSerializable() -class InternalItem { - final String id; - final String? name; - final String type; - - InternalItem({ - required this.id, - this.name, - required this.type, - }); - - factory InternalItem.fromJson(Map json) => - _$InternalItemFromJson(json); - - Map toJson() => _$InternalItemToJson(this); -} diff --git a/lib/features/connection/types/stremio.dart b/lib/features/connection/types/stremio.dart deleted file mode 100644 index d1799ae..0000000 --- a/lib/features/connection/types/stremio.dart +++ /dev/null @@ -1 +0,0 @@ -export '../../connections/types/stremio/stremio_base.types.dart'; diff --git a/lib/features/connections/service/base_connection_service.dart b/lib/features/connections/service/base_connection_service.dart deleted file mode 100644 index d5c8fb4..0000000 --- a/lib/features/connections/service/base_connection_service.dart +++ /dev/null @@ -1,271 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:madari_client/engine/connection_type.dart'; -import 'package:madari_client/engine/engine.dart'; -import 'package:madari_client/features/connections/service/stremio_connection_service.dart'; -import 'package:madari_client/features/doc_viewer/types/doc_source.dart'; -import 'package:pocketbase/pocketbase.dart'; - -import '../../settings/types/connection.dart'; -import '../types/base/base.dart'; -import '../widget/stremio/stremio_create.dart'; - -abstract class BaseConnectionService { - Widget renderCard(LibraryItem item, String heroPrefix); - Widget renderList(LibraryItem item, String heroPrefix); - - static final Map _item = {}; - - final String connectionId; - - static Future getLibraries() async { - final library = - await AppEngine.engine.pb.collection("library").getFullList(); - - return LibraryRecordResponse( - data: library - .map( - (item) => LibraryRecord.fromRecord(item), - ) - .toList(), - ); - } - - factory BaseConnectionService.create( - Connection item, - ConnectionTypeRecord type, - ) { - switch (type.type) { - case "stremio_addons": - return StremioConnectionService( - connectionId: item.id, - config: StremioConfig.fromJson(item.config), - ); - } - - throw ErrorDescription("Connection is not supported"); - } - - static Future connectionByIdRaw( - String connectionId, - ) async { - RecordModel model_; - - if (_item.containsKey(connectionId)) { - model_ = _item[connectionId]!; - } else { - model_ = await AppEngine.engine.pb - .collection("connection") - .getOne(connectionId, expand: "type"); - _item[connectionId] = model_; - } - - return ConnectionResponse( - connection: Connection.fromRecord(model_), - connectionTypeRecord: ConnectionTypeRecord.fromRecord( - model_.get("expand.type"), - ), - ); - } - - static BaseConnectionService connectionById( - ConnectionResponse connection, - ) { - return BaseConnectionService.create( - connection.connection, - connection.connectionTypeRecord, - ); - } - - static Widget createTypeWidget(String type, OnSuccessCallback onSuccess) { - switch (type) { - case "stremio": - return const StremioCreateConnection(); - } - - throw ErrorDescription("Connection is not supported"); - } - - Future> getItems( - LibraryRecord library, { - List? items, - int? page, - int? perPage, - String? cursor, - }); - - Future> getBulkItem( - List ids, - ); - - Future>> getFilters( - LibraryRecord library, - ); - - Future getItemById(LibraryItem id); - - Future getStreams( - LibraryItem id, { - OnStreamCallback? callback, - }); - - BaseConnectionService({ - required this.connectionId, - }); -} - -class StreamList { - final String title; - final String? description; - final DocSource source; - final StreamSource? streamSource; - - StreamList({ - required this.title, - this.description, - required this.source, - this.streamSource, - }); -} - -class StreamSource { - final String title; - final String id; - - StreamSource({ - required this.title, - required this.id, - }); -} - -class ConnectionResponse { - final Connection connection; - final ConnectionTypeRecord connectionTypeRecord; - - ConnectionResponse({ - required this.connectionTypeRecord, - required this.connection, - }); - - Map toJson() { - return { - "connection": connection, - "connectionTypeRecord": connectionTypeRecord, - }; - } -} - -typedef OnSuccessCallback = void Function(String connectionId); - -class LibraryRecordResponse extends Jsonable { - final List data; - - LibraryRecordResponse({ - required this.data, - }); - - @override - Map toJson() { - return { - "data": data.map((item) => item.toJson()).toList(), - }; - } -} - -class ConnectionFilter { - final String title; - final ConnectionFilterType type; - final List? values; - - ConnectionFilter({ - required this.title, - required this.type, - this.values, - }); -} - -enum ConnectionFilterType { - text, - options, -} - -class ConnectionFilterItem { - final String title; - final dynamic value; - - ConnectionFilterItem({ - required this.title, - required this.value, - }); -} - -abstract class LibraryItem extends Jsonable { - late final String id; - - LibraryItem({ - required this.id, - }); - - @override - Map toJson(); -} - -abstract class PaginatedResult { - List get items; - bool get hasMore; - - Map toJson() { - return { - "items": items.map((res) => res.toJson()), - "hasMore": hasMore, - }; - } -} - -class CursorPaginatedResult - implements PaginatedResult { - @override - final List items; - @override - final bool hasMore; - final String? nextCursor; - - CursorPaginatedResult({ - required this.items, - required this.hasMore, - this.nextCursor, - }); - - Map toJson() { - return { - "items": items.map((res) => res.toJson()), - "hasMore": hasMore, - "nextCursor": nextCursor, - }; - } -} - -class PagePaginatedResult implements PaginatedResult { - @override - final List items; - @override - final bool hasMore; - final int totalPages; - final int currentPage; - - PagePaginatedResult({ - required this.items, - required this.hasMore, - required this.totalPages, - required this.currentPage, - }); - - @override - Map toJson() { - return { - "items": items.map((res) => res.toJson()).toList(), - "hasMore": hasMore, - "totalPages": totalPages, - "currentPage": currentPage, - }; - } -} diff --git a/lib/features/connections/service/stremio_connection_service.dart b/lib/features/connections/service/stremio_connection_service.dart deleted file mode 100644 index 918ce4d..0000000 --- a/lib/features/connections/service/stremio_connection_service.dart +++ /dev/null @@ -1,700 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:cached_query/cached_query.dart'; -import 'package:flutter/material.dart'; -import 'package:http/http.dart' as http; -import 'package:json_annotation/json_annotation.dart'; -import 'package:logging/logging.dart'; -import 'package:madari_client/features/connections/types/base/base.dart'; -import 'package:madari_client/features/connections/widget/stremio/stremio_card.dart'; -import 'package:madari_client/features/connections/widget/stremio/stremio_list_item.dart'; -import 'package:madari_client/features/doc_viewer/types/doc_source.dart'; -import 'package:madari_client/utils/common.dart'; -import 'package:pocketbase/pocketbase.dart'; - -import '../../connection/services/stremio_service.dart'; -import '../types/stremio/stremio_base.types.dart'; -import './base_connection_service.dart'; - -part 'stremio_connection_service.g.dart'; - -final Map manifestCache = {}; - -typedef OnStreamCallback = void Function(List? items, Error?); - -class StremioConnectionService extends BaseConnectionService { - final StremioConfig config; - final Logger _logger = Logger('StremioConnectionService'); - - StremioConnectionService({ - required super.connectionId, - required this.config, - }) { - _logger.info('StremioConnectionService initialized with config: $config'); - } - - @override - Future getItemById(LibraryItem id) async { - _logger.fine('Fetching item by ID: ${id.id}'); - return Query( - key: "meta_${id.id}", - config: QueryConfig( - cacheDuration: const Duration(days: 30), - refetchDuration: (id as Meta).type == "movie" - ? const Duration(days: 30) - : const Duration(days: 1), - ), - queryFn: () async { - for (final addon in config.addons) { - _logger.finer('Checking addon: $addon'); - final manifest = await _getManifest(addon); - - if (manifest.resources == null) { - _logger.finer('No resources found in manifest for addon: $addon'); - continue; - } - - List idPrefixes = []; - bool isMeta = false; - - for (final item in manifest.resources!) { - if (item.name == "meta") { - idPrefixes - .addAll((item.idPrefix ?? []) + (item.idPrefixes ?? [])); - isMeta = true; - break; - } - } - - if (!isMeta) { - _logger - .finer('No meta resource found in manifest for addon: $addon'); - continue; - } - - final ids = ((manifest.idPrefixes ?? []) + idPrefixes) - .firstWhere((item) => id.id.startsWith(item), orElse: () => ""); - - if (ids.isEmpty) { - _logger.finer('No matching ID prefix found for addon: $addon'); - continue; - } - - final result = await http.get( - Uri.parse( - "${_getAddonBaseURL(addon)}/meta/${id.type}/${id.id}.json"), - ); - - final item = jsonDecode(result.body); - - if (item['meta'] == null) { - _logger.finer( - 'No meta data found for item: ${id.id} in addon: $addon'); - return null; - } - - return StreamMetaResponse.fromJson(item).meta; - } - - _logger.warning('No meta data found for item: ${id.id} in any addon'); - return null; - }, - ) - .stream - .where((item) => item.status != QueryStatus.loading) - .first - .then((docs) { - if (docs.error != null) { - _logger.severe('Error fetching item by ID: ${docs.error}'); - throw docs.error!; - } - return docs.data; - }); - } - - List getConfig(dynamic configOutput) { - _logger.fine('Parsing config output'); - final List configItems = []; - - for (final item in configOutput) { - final itemToPush = InternalManifestItemConfig.fromJson( - jsonDecode(item), - ); - configItems.add(itemToPush); - } - - _logger.finer('Config parsed successfully: $configItems'); - return configItems; - } - - Stream> getSubtitles(Meta record) async* { - final List subtitles = []; - - _logger.info('getting subtitles'); - - for (final addon in config.addons) { - final manifest = await _getManifest(addon); - - final resource = manifest.resources - ?.firstWhereOrNull((res) => res.name == "subtitles"); - - if (resource == null) { - continue; - } - - final types = resource.types ?? manifest.types ?? []; - final idPrefixes = - resource.idPrefixes ?? resource.idPrefix ?? manifest.idPrefixes; - - if (!types.contains(record.type)) { - continue; - } - - final hasPrefixMatch = idPrefixes?.firstWhereOrNull((item) { - return record.id.startsWith(item); - }); - - if (hasPrefixMatch == null) { - continue; - } - - final addonBase = _getAddonBaseURL(addon); - - final url = - "$addonBase/subtitles/${record.type}/${Uri.encodeQueryComponent(record.currentVideo?.id ?? record.id)}.json"; - - _logger.info('loading subtitles from $url'); - - final body = await http.get(Uri.parse(url)); - - if (body.statusCode != 200) { - _logger.warning('failed due to status code ${body.statusCode}'); - continue; - } - - final dataBody = jsonDecode(body.body); - - try { - final responses = SubtitleResponse.fromJson(dataBody); - subtitles.addAll(responses.subtitles); - yield subtitles; - } catch (e) { - _logger.warning("failed to parse subtitle response"); - } - } - } - - @override - Future> getItems( - LibraryRecord library, { - List? items, - int? page, - int? perPage, - String? cursor, - }) async { - _logger.fine('Fetching items for library: ${library.id}'); - final List returnValue = []; - final configItems = getConfig(library.config); - - bool hasMore = false; - - const perPage = 50; - - items = [...(items ?? [])]; - - if (page != null) { - items.add( - ConnectionFilterItem( - title: "skip", - value: page * perPage, - ), - ); - } - - for (final item in configItems) { - String url = - "${_getAddonBaseURL(item.addon)}/catalog/${item.item.type}/${item.item.id}"; - - if (items.isNotEmpty) { - String filterPath = items.map((filter) { - return "${filter.title}=${Uri.encodeComponent(filter.value.toString())}"; - }).join('&'); - - if (filterPath.isNotEmpty) { - url += "/$filterPath"; - } - } - - url += ".json"; - - final result = await Query( - config: QueryConfig( - cacheDuration: const Duration(hours: 8), - ), - queryFn: () async { - try { - _logger.finer('Fetching catalog from URL: $url'); - final httpBody = await http.get(Uri.parse(url)); - return StrmioMeta.fromJson( - jsonDecode(httpBody.body), - ); - } catch (e, stack) { - _logger.severe('Error parsing catalog $url', e, stack); - rethrow; - } - }, - key: url, - ) - .stream - .where((item) => item.status != QueryStatus.loading) - .first - .then((docs) { - if (docs.error != null) { - _logger.severe('Error fetching catalog', docs.error); - throw docs.error!; - } - return docs.data!; - }); - - hasMore = result.hasMore ?? false; - returnValue.addAll(result.metas ?? []); - } - - _logger.finer('Items fetched successfully: ${returnValue.length} items'); - return PagePaginatedResult( - items: returnValue.toList(), - currentPage: page ?? 1, - totalPages: 0, - hasMore: hasMore, - ); - } - - @override - Widget renderCard(LibraryItem item, String heroPrefix) { - _logger.fine('Rendering card for item: ${item.id}'); - return StremioCard( - item: item, - prefix: heroPrefix, - connectionId: connectionId, - service: this, - ); - } - - @override - Future> getBulkItem(List ids) async { - _logger.fine('Fetching bulk items: ${ids.length} items'); - if (ids.isEmpty) { - _logger.finer('No items to fetch'); - return []; - } - - return (await Future.wait( - ids.map( - (res) async { - return getItemById(res).then((item) { - if (item == null) { - _logger.finer('Item not found: ${res.id}'); - return null; - } - - return (item as Meta).copyWith( - progress: (res as Meta).progress, - selectedVideoIndex: res.selectedVideoIndex, - ); - }).catchError((err, stack) { - _logger.severe('Error fetching item: ${res.id}', err, stack); - return (res as Meta); - }); - }, - ), - )) - .whereType() - .toList(); - } - - @override - Widget renderList(LibraryItem item, String heroPrefix) { - _logger.fine('Rendering list item: ${item.id}'); - return StremioListItem(item: item); - } - - Future _getManifest(String url) async { - _logger.fine('Fetching manifest from URL: $url'); - return Query( - key: url, - config: QueryConfig( - cacheDuration: const Duration(days: 30), - refetchDuration: const Duration(days: 1), - ), - queryFn: () async { - final String result; - if (manifestCache.containsKey(url)) { - _logger.finer('Manifest found in cache for URL: $url'); - result = manifestCache[url]!; - } else { - _logger.finer('Fetching manifest from network for URL: $url'); - result = (await http.get(Uri.parse(url))).body; - manifestCache[url] = result; - } - - final body = jsonDecode(result); - final resultFinal = StremioManifest.fromJson(body); - _logger.finer('Manifest successfully parsed for URL: $url'); - return resultFinal; - }, - ) - .stream - .where((item) => item.status != QueryStatus.loading) - .first - .then((docs) { - if (docs.error != null) { - _logger.severe('Error fetching manifest: ${docs.error}'); - throw docs.error!; - } - return docs.data!; - }); - } - - String _getAddonBaseURL(String input) { - return input.endsWith("/manifest.json") - ? input.replaceAll("/manifest.json", "") - : input; - } - - @override - Future>> getFilters(LibraryRecord library) async { - _logger.fine('Fetching filters for library: ${library.id}'); - final configItems = getConfig(library.config); - List> filters = []; - - try { - for (final addon in configItems) { - final addonManifest = await _getManifest(addon.addon); - - if ((addonManifest.catalogs?.isEmpty ?? true) == true) { - _logger.finer('No catalogs found for addon: ${addon.addon}'); - continue; - } - - final catalogs = addonManifest.catalogs!.where((item) { - return item.id == addon.item.id && item.type == addon.item.type; - }).toList(); - - for (final catalog in catalogs) { - if (catalog.extra == null) { - _logger.finer('No extra filters found for catalog: ${catalog.id}'); - continue; - } - for (final extraItem in catalog.extra!) { - if (extraItem.options == null || - extraItem.options?.isEmpty == true) { - filters.add( - ConnectionFilter( - title: extraItem.name, - type: ConnectionFilterType.text, - ), - ); - } else { - filters.add( - ConnectionFilter( - title: extraItem.name, - type: ConnectionFilterType.options, - values: extraItem.options?.whereType().toList(), - ), - ); - } - } - } - } - } catch (e) { - _logger.severe('Error fetching filters', e); - } - - _logger.finer('Filters fetched successfully: $filters'); - return filters; - } - - @override - Future getStreams( - LibraryItem id, { - OnStreamCallback? callback, - }) async { - _logger.fine('Fetching streams for item: ${id.id}'); - final List streams = []; - final meta = id as Meta; - - final List> promises = []; - - for (final addon in config.addons) { - final future = Future.delayed(const Duration(seconds: 0), () async { - final addonManifest = await _getManifest(addon); - - for (final resource_ in (addonManifest.resources ?? [])) { - final resource = resource_ as ResourceObject; - - if (!doesAddonSupportStream(resource, addonManifest, meta)) { - _logger.finer( - 'Addon does not support stream: ${addonManifest.name}', - ); - continue; - } - - final url = - "${_getAddonBaseURL(addon)}/stream/${meta.type}/${Uri.encodeComponent(id.currentVideo?.id ?? id.id)}.json"; - - final result = await Query( - key: url, - queryFn: () async { - final result = await http.get(Uri.parse(url), headers: {}); - - if (result.statusCode == 404) { - _logger.warning( - 'Invalid status code for addon: ${addonManifest.name}', - ); - if (callback != null) { - callback( - null, - ArgumentError( - "Invalid status code for the addon ${addonManifest.name} with id ${addonManifest.id}", - ), - ); - } - } - - return result.body; - }, - ) - .stream - .where((item) => item.status != QueryStatus.loading) - .first - .then((docs) { - return docs.data; - }); - - if (result == null) { - _logger.finer('No stream data found for URL: $url'); - continue; - } - - final body = StreamResponse.fromJson(jsonDecode(result)); - - streams.addAll( - body.streams - .map( - (item) => videoStreamToStreamList( - item, - meta, - addonManifest, - ), - ) - .whereType() - .toList(), - ); - - if (callback != null) { - callback(streams, null); - } - } - }).catchError((error, stacktrace) { - _logger.severe('Error fetching streams', error, stacktrace); - if (callback != null) callback(null, error); - }); - - promises.add(future); - } - - await Future.wait(promises); - _logger.finer('Streams fetched successfully: ${streams.length} streams'); - return; - } - - bool doesAddonSupportStream( - ResourceObject resource, - StremioManifest addonManifest, - Meta meta, - ) { - if (resource.name != "stream") { - _logger.finer('Resource is not a stream: ${resource.name}'); - return false; - } - - final idPrefixes = - resource.idPrefixes ?? addonManifest.idPrefixes ?? resource.idPrefix; - - final types = resource.types ?? addonManifest.types; - - if (types == null || !types.contains(meta.type)) { - _logger.finer('Addon does not support type: ${meta.type}'); - return false; - } - - if ((idPrefixes ?? []).isEmpty == true) { - _logger.finer('No ID prefixes found, assuming support'); - return true; - } - - final hasIdPrefix = (idPrefixes ?? []).where( - (item) => meta.id.startsWith(item), - ); - - if (hasIdPrefix.isEmpty) { - _logger.finer('No matching ID prefix found'); - return false; - } - - _logger.finer('Addon supports stream'); - return true; - } - - StreamList? videoStreamToStreamList( - VideoStream item, - Meta meta, - StremioManifest addonManifest, - ) { - String streamTitle = (item.name != null - ? "${(item.name ?? "")} ${(item.title ?? "")}" - : item.title) ?? - "No title"; - - try { - streamTitle = utf8.decode(streamTitle.runes.toList()); - } catch (e) {} - - String? streamDescription = item.description; - - try { - streamDescription = item.description != null - ? utf8.decode((item.description!).runes.toList()) - : null; - } catch (e) {} - - String title = meta.name ?? item.title ?? "No title"; - - DocSource? source; - - if (item.url != null) { - source = MediaURLSource( - title: title, - url: item.url!, - id: meta.id, - ); - } - - if (item.infoHash != null) { - source = TorrentSource( - title: title, - infoHash: item.infoHash!, - id: meta.id, - fileName: "$title.mp4", - ); - } - - if (source == null) { - _logger.finer('No valid source found for stream'); - return null; - } - - String addonName = addonManifest.name; - - try { - addonName = utf8.decode((addonName).runes.toList()); - } catch (e) { - _logger.warning('Failed to decode addon name', e); - } - - _logger.finer('Stream list created successfully'); - return StreamList( - title: streamTitle, - description: streamDescription, - source: source, - streamSource: StreamSource( - title: addonName, - id: addonManifest.id, - ), - ); - } -} - -@JsonSerializable() -class StremioConfig { - List addons; - - StremioConfig({ - required this.addons, - }); - - factory StremioConfig.fromRecord(RecordModel record) => - StremioConfig.fromJson(record.toJson()); - - factory StremioConfig.fromJson(Map json) => - _$StremioConfigFromJson(json); - - Map toJson() => _$StremioConfigToJson(this); -} - -class Subtitle { - final String id; - final String url; - final String? subEncoding; - final String? lang; - final String? m; - final String? g; // Making g optional since some entries have empty string - - const Subtitle({ - required this.id, - required this.url, - required this.subEncoding, - required this.lang, - required this.m, - this.g, - }); - - factory Subtitle.fromJson(Map json) { - return Subtitle( - id: json['id'] as String, - url: json['url'] as String, - subEncoding: json['SubEncoding'] as String?, - lang: json['lang'] as String?, - m: json['m'] as String?, - g: json['g'] as String?, - ); - } - - Map toJson() { - return { - 'id': id, - 'url': url, - 'SubEncoding': subEncoding, - 'lang': lang, - 'm': m, - 'g': g, - }; - } -} - -class SubtitleResponse { - final List subtitles; - final int? cacheMaxAge; - - const SubtitleResponse({ - required this.subtitles, - required this.cacheMaxAge, - }); - - factory SubtitleResponse.fromJson(Map json) { - return SubtitleResponse( - subtitles: (json['subtitles'] as List) - .map((e) => Subtitle.fromJson(e as Map)) - .toList(), - cacheMaxAge: json['cacheMaxAge'] as int?, - ); - } - - Map toJson() { - return { - 'subtitles': subtitles.map((e) => e.toJson()).toList(), - 'cacheMaxAge': cacheMaxAge, - }; - } -} diff --git a/lib/features/connections/types/base/base.dart b/lib/features/connections/types/base/base.dart deleted file mode 100644 index d34eb0e..0000000 --- a/lib/features/connections/types/base/base.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; -import 'package:pocketbase/pocketbase.dart'; - -part 'base.g.dart'; - -@JsonSerializable() -class LibraryRecord extends Jsonable { - final String id; - final String icon; - final String title; - final List types; - final dynamic config; - final String connection; - final String connectionType; - - LibraryRecord({ - required this.id, - required this.icon, - required this.title, - required this.types, - required this.config, - required this.connection, - required this.connectionType, - }); - - factory LibraryRecord.fromRecord(RecordModel record) => - LibraryRecord.fromJson(record.toJson()); - - factory LibraryRecord.fromJson(Map json) => - _$LibraryRecordFromJson(json); - - @override - Map toJson() => _$LibraryRecordToJson(this); -} diff --git a/lib/features/connections/widget/base/render_library_list.dart b/lib/features/connections/widget/base/render_library_list.dart deleted file mode 100644 index f114c70..0000000 --- a/lib/features/connections/widget/base/render_library_list.dart +++ /dev/null @@ -1,609 +0,0 @@ -import 'package:cached_query_flutter/cached_query_flutter.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; -import 'package:madari_client/engine/engine.dart'; -import 'package:madari_client/features/connections/service/base_connection_service.dart'; -import 'package:madari_client/features/connections/types/base/base.dart'; -import 'package:pocketbase/pocketbase.dart'; -import 'package:shimmer/shimmer.dart'; - -import '../../../../utils/grid.dart'; -import '../stremio/stremio_filter.dart'; - -final pb = AppEngine.engine.pb; - -class RenderLibraryList extends StatefulWidget { - final LibraryRecord item; - final List filters; - final bool isGrid; - - const RenderLibraryList({ - super.key, - required this.item, - required this.filters, - this.isGrid = false, - }); - - @override - State createState() => _RenderLibraryListState(); -} - -class _RenderLibraryListState extends State { - late final query = Query( - key: widget.item.id, - queryFn: () => BaseConnectionService.connectionByIdRaw( - widget.item.connection, - ), - ); - - @override - Widget build(BuildContext context) { - return QueryBuilder( - query: query, - builder: (ctx, state) { - if (state.status == QueryStatus.loading) { - return const Center( - child: SpinnerCards(), - ); - } - - if (state.status == QueryStatus.error) { - final errorMessage = ( - state.error is ClientException - ? (state.error as ClientException).response["message"] - : "", - ); - - return SizedBox( - height: getListHeight(context), - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: Container( - color: Colors.black45, - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: 16, - horizontal: 16, - ), - child: Center( - child: Text( - "Something went wrong while loading library\n${errorMessage.$1}", - textAlign: TextAlign.center, - style: GoogleFonts.exo2().copyWith( - fontSize: 16, - ), - ), - ), - ), - ), - ), - ); - } - - try { - return _RenderLibraryList( - item: widget.item, - service: state.data!, - filters: widget.filters, - isGrid: widget.isGrid, - ); - } catch (e) { - return Text("Error $e"); - } - }, - ); - } -} - -class _RenderLibraryList extends StatefulWidget { - final LibraryRecord item; - final ConnectionResponse service; - final List filters; - final bool isGrid; - - const _RenderLibraryList({ - required this.item, - required this.service, - required this.filters, - required this.isGrid, - }); - - @override - State<_RenderLibraryList> createState() => __RenderLibraryListState(); -} - -class __RenderLibraryListState extends State<_RenderLibraryList> { - late BaseConnectionService service = BaseConnectionService.connectionById( - widget.service, - ); - - final _scrollController = ScrollController(); - - bool get _isBottom { - if (!_scrollController.hasClients) return false; - final maxScroll = _scrollController.position.maxScrollExtent; - final currentScroll = _scrollController.offset; - return currentScroll >= (maxScroll * 0.9); - } - - void _onScroll() { - if (_isBottom && query.state.status != QueryStatus.loading) { - query.getNextPage(); - } - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - query = getQuery(); - } - - @override - void initState() { - super.initState(); - _scrollController.addListener(_onScroll); - loadFilters(); - } - - @override - void dispose() { - _scrollController - ..removeListener(_onScroll) - ..dispose(); - super.dispose(); - } - - List filters = []; - - InfiniteQuery getQuery() { - return InfiniteQuery, int>( - key: - "loadLibrary${widget.item.id}${(widget.filters + filters).map((res) => "${res.title}=${res.value}").join("&")}", - queryFn: (page) { - return service - .getItems( - widget.item, - items: widget.filters + filters, - page: page, - ) - .then((docs) { - return docs.items.toList(); - }).catchError((e, stack) { - throw e; - }); - }, - getNextArg: (state) { - if (state.lastPage?.isEmpty ?? false) return null; - return state.length; - }, - ); - } - - late InfiniteQuery query = getQuery(); - - bool isUnsupported = false; - - loadFilters() async { - final filters = await service.getFilters(widget.item); - - if (mounted) { - setState(() { - filterList = filters; - }); - } - } - - List? filterList; - - @override - Widget build(BuildContext context) { - if (widget.isGrid) { - return Scaffold( - appBar: AppBar( - title: Text(widget.item.title), - ), - body: SizedBox( - height: MediaQuery.of(context).size.height - 96, - child: Flex( - direction: Axis.vertical, - children: [ - const SizedBox( - height: 10, - ), - if (filterList == null) - Row( - children: [ - SizedBox( - height: 36, - width: 120, - child: Padding( - padding: const EdgeInsets.only( - left: 10.0, - right: 10.0, - ), - child: Container( - decoration: BoxDecoration( - color: Colors.grey, - borderRadius: BorderRadius.circular(20), - ), - child: const SizedBox( - height: 36, - width: 120, - ), - ), - ), - ), - ], - ), - if (filterList != null) - InlineFilters( - filters: filterList ?? [], - filterCallback: (item) { - filters = item; - - setState(() { - query = getQuery(); - }); - }, - ), - const SizedBox( - height: 10, - ), - Expanded( - child: SizedBox( - height: MediaQuery.of(context).size.height - 96, - child: Padding( - padding: const EdgeInsets.only( - left: 10.0, - right: 10.0, - ), - child: _buildBody(), - ), - ), - ), - ], - ), - ), - ); - } - - return _buildBody(); - } - - _buildBody() { - final listHeight = getListHeight(context); - - if (isUnsupported) { - return SizedBox( - height: listHeight, - child: const Text("This connection is not supported "), - ); - } - - return SizedBox( - height: listHeight, - child: InfiniteQueryBuilder( - query: query, - builder: (context, data, query) { - final items = (data.data?.expand((e) => e).toList() ?? []) - .whereType() - .toList(); - - return RenderListItems( - hasError: data.status == QueryStatus.error, - onRefresh: () { - query.refetch(); - }, - loadMore: () { - query.getNextPage(); - }, - itemScrollController: _scrollController, - isLoadingMore: data.status == QueryStatus.loading && items.isEmpty, - isGrid: widget.isGrid, - items: items, - heroPrefix: widget.item.id, - service: service, - ); - }, - ), - ); - } -} - -typedef OnContextTap = void Function( - String actionId, - LibraryItem item, -); - -class ContextMenuItem { - final String id; - final String title; - final bool isDefaultAction; - final bool isDestructiveAction; - final IconData? icon; - final OnContextTap? onCallback; - - ContextMenuItem({ - required this.title, - this.isDefaultAction = false, - this.isDestructiveAction = false, - this.icon, - required this.id, - this.onCallback, - }); -} - -class RenderListItems extends StatefulWidget { - final ScrollController? controller; - final ScrollController? itemScrollController; - final bool isGrid; - final bool hasError; - final VoidCallback? onRefresh; - final BaseConnectionService service; - final List items; - final String heroPrefix; - final dynamic error; - final bool isWide; - final bool isLoadingMore; - final VoidCallback? loadMore; - final List contextMenuItems; - final OnContextTap? onContextMenu; - - const RenderListItems({ - super.key, - this.controller, - this.isGrid = false, - this.hasError = false, - this.onRefresh, - required this.items, - required this.service, - required this.heroPrefix, - this.itemScrollController, - this.error, - this.isWide = false, - this.isLoadingMore = false, - this.loadMore, - this.contextMenuItems = const [], - this.onContextMenu, - }); - - @override - State createState() => _RenderListItemsState(); -} - -class _RenderListItemsState extends State { - @override - Widget build(BuildContext context) { - final listHeight = getListHeight(context); - final itemWidth = getItemWidth( - context, - isWide: widget.isWide, - ); - - return CustomScrollView( - controller: widget.controller, - physics: widget.isGrid - ? const AlwaysScrollableScrollPhysics() - : const NeverScrollableScrollPhysics(), - slivers: [ - if (widget.hasError) - SliverToBoxAdapter( - child: SizedBox( - height: listHeight, - child: Container( - width: double.infinity, - decoration: BoxDecoration( - color: Colors.grey[900], - borderRadius: BorderRadius.circular(10), - ), - child: Center( - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - "Something went wrong while loading the library \n${widget.error}", - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyLarge, - ), - const SizedBox( - height: 10, - ), - TextButton.icon( - label: const Text("Retry"), - onPressed: widget.onRefresh, - icon: const Icon( - Icons.refresh, - ), - ) - ], - ), - ), - ), - ), - ), - if (widget.isGrid) ...[ - SliverGrid.builder( - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: getGridResponsiveColumnCount(context), - mainAxisSpacing: getGridResponsiveSpacing(context), - crossAxisSpacing: getGridResponsiveSpacing(context), - childAspectRatio: 2 / 3, - ), - itemCount: widget.items.length, - itemBuilder: (ctx, index) { - final item = widget.items[index]; - - return widget.service.renderCard( - item, - "${index}_${widget.heroPrefix}", - ); - }, - ), - if (widget.isLoadingMore) - SliverPadding( - padding: const EdgeInsets.only( - top: 8.0, - right: 8.0, - ), - sliver: SliverGrid( - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: getGridResponsiveColumnCount(context), - mainAxisSpacing: getGridResponsiveSpacing(context), - crossAxisSpacing: getGridResponsiveSpacing(context), - childAspectRatio: 2 / 3, - ), - delegate: SliverChildBuilderDelegate( - (ctx, index) { - return ClipRRect( - borderRadius: BorderRadius.circular(10), - child: Shimmer.fromColors( - baseColor: Colors.grey[800]!, - highlightColor: Colors.grey[700]!, - child: Container( - color: Colors.grey[800], - ), - ), - ); - }, - childCount: 4, // Fixed number of loading items - ), - ), - ), - ] else ...[ - if (!widget.isLoadingMore) - SliverToBoxAdapter( - child: CupertinoPageScaffold( - resizeToAvoidBottomInset: true, - child: SizedBox( - height: listHeight, - child: ListView.builder( - controller: widget.itemScrollController, - itemBuilder: (ctx, index) { - final item = widget.items[index]; - - if (widget.contextMenuItems.isEmpty) { - return SizedBox( - width: itemWidth, - child: Container( - child: widget.service.renderCard( - item, - "${index}_${widget.heroPrefix}", - ), - ), - ); - } - - return CupertinoContextMenu( - enableHapticFeedback: true, - actions: widget.contextMenuItems.map((menu) { - return CupertinoContextMenuAction( - isDefaultAction: menu.isDefaultAction, - isDestructiveAction: menu.isDestructiveAction, - trailingIcon: menu.icon, - onPressed: () { - if (widget.onContextMenu != null) { - widget.onContextMenu!( - menu.id, - item, - ); - } - }, - child: Text(menu.title), - ); - }).toList(), - child: SizedBox( - width: itemWidth, - child: Container( - constraints: BoxConstraints( - maxHeight: listHeight, - ), - child: widget.service.renderCard( - item, - "${index}_${widget.heroPrefix}", - ), - ), - ), - ); - }, - scrollDirection: Axis.horizontal, - itemCount: widget.items.length, - ), - ), - ), - ), - if (widget.isLoadingMore) - SliverToBoxAdapter( - child: SpinnerCards( - isWide: widget.isWide, - ), - ), - ], - SliverPadding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).padding.bottom, - ), - ), - ], - ); - } -} - -class SpinnerCards extends StatelessWidget { - final bool isWide; - const SpinnerCards({ - super.key, - this.isWide = false, - }); - - @override - Widget build(BuildContext context) { - final itemWidth = getItemWidth( - context, - isWide: isWide, - ); - final itemHeight = getListHeight(context); - - return SizedBox( - height: itemHeight, - child: ListView.builder( - scrollDirection: Axis.horizontal, - physics: const NeverScrollableScrollPhysics(), - itemBuilder: (context, _) { - return SizedBox( - width: itemWidth, - child: Container( - margin: const EdgeInsets.only( - right: 8, - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: Shimmer.fromColors( - baseColor: Colors.grey[800]!, - highlightColor: Colors.grey[700]!, - child: Container( - color: Colors.grey[800], - ), - ), - ), - ), - ); - }, - itemCount: 10, - ), - ); - } -} - -double getItemWidth(BuildContext context, {bool isWide = false}) { - double screenWidth = MediaQuery.of(context).size.width; - return screenWidth > 800 - ? (isWide ? 400.0 : 200.0) - : (isWide ? 280.0 : 120.0); -} - -double getListHeight(BuildContext context) { - double screenWidth = MediaQuery.of(context).size.width; - return screenWidth > 800 ? 300.0 : 180.0; -} diff --git a/lib/features/connections/widget/base/render_stream_list.dart b/lib/features/connections/widget/base/render_stream_list.dart deleted file mode 100644 index 0dd85be..0000000 --- a/lib/features/connections/widget/base/render_stream_list.dart +++ /dev/null @@ -1,322 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:background_downloader/background_downloader.dart'; -import 'package:flutter/material.dart'; -import 'package:madari_client/features/connection/types/stremio.dart'; -import 'package:madari_client/features/connections/service/base_connection_service.dart'; -import 'package:madari_client/features/doc_viewer/container/doc_viewer.dart'; - -import '../../../../utils/external_player.dart'; -import '../../../../utils/load_language.dart'; -import '../../../doc_viewer/types/doc_source.dart'; -import '../../../downloads/service/service.dart'; - -// Note: This is because there is some conflict between drift and this -const kIsWeb = bool.fromEnvironment('dart.library.js_util'); - -class RenderStreamList extends StatefulWidget { - final BaseConnectionService service; - final LibraryItem id; - final bool shouldPop; - final double? progress; - - const RenderStreamList({ - super.key, - required this.service, - required this.id, - this.progress, - required this.shouldPop, - }); - - @override - State createState() => _RenderStreamListState(); -} - -class _RenderStreamListState extends State { - final Map _downloadProgress = {}; - final Map _downloadError = {}; - - late StreamSubscription _hasError; - - @override - void initState() { - super.initState(); - - getLibrary(); - - if (!kIsWeb) { - DownloadService.instance.getAllDownloads().then((data) { - for (var item in data) { - _downloadProgress[item.taskId] = item.progress; - - if (item.exception?.description != null) { - _downloadError[item.taskId] = item.exception!.description; - } - } - - if (mounted) { - setState(() {}); - } - }); - } - - if (!kIsWeb) { - _hasError = DownloadService.instance.updates.listen((update) async { - if (update is TaskStatusUpdate) { - final task = - await DownloadService.instance.getById(update.task.taskId); - - if (mounted) { - setState(() { - _downloadProgress[update.task.taskId] = task?.progress ?? 0; - if (task?.exception?.description != null) { - _downloadError[update.task.taskId] = - task!.exception!.description; - } - }); - } else { - _hasError.cancel(); - } - } - }); - } - } - - @override - void dispose() { - super.dispose(); - - _hasError.cancel(); - } - - Widget _buildDownloadButton(BuildContext context, String url, String title) { - final taskId = calculateHash(url); - final progress = _downloadProgress[taskId]; - - return SizedBox( - child: progress != null - ? Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - value: progress, - strokeWidth: 2, - ), - ), - IconButton( - icon: _downloadError[taskId] == null - ? const Icon(Icons.stop_circle) - : const Icon(Icons.delete), - onPressed: () async { - if (_downloadError[taskId] == null) { - final task = - await DownloadService.instance.getById(taskId); - await DownloadService.instance.pauseDownload( - task!.task as DownloadTask, - ); - } else { - DownloadService.instance.deleteDownload(taskId); - setState(() { - _downloadProgress.remove(taskId); - }); - } - }, - ), - ], - ) - : IconButton( - icon: const Icon(Icons.download), - onPressed: () async { - final task = DownloadTask( - url: url, - taskId: taskId, - filename: "${(widget.id as Meta).name!}.mp4", - ); - - await DownloadService.instance.startDownload(task); - - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: const Text('Download started'), - action: SnackBarAction( - label: 'View', - onPressed: () { - Navigator.pushNamed(context, '/downloads'); - }, - ), - ), - ); - }, - ), - ); - } - - bool hasError = false; - bool isLoading = true; - List? _list; - - final List errors = []; - - final Map _sources = {}; - - Future getLibrary() async { - await BaseConnectionService.getLibraries(); - - await widget.service.getStreams( - widget.id, - callback: (items, error) { - if (mounted) { - setState(() { - isLoading = false; - _list = items; - - _list?.forEach((item) { - if (item.streamSource != null) { - _sources[item.streamSource!.id] = item.streamSource!; - } - }); - }); - } - }, - ); - - if (mounted) { - setState(() { - isLoading = false; - _list = _list ?? []; - }); - } - } - - String? selectedAddonFilter; - - @override - Widget build(BuildContext context) { - if (isLoading || _list == null) { - return const Center( - child: CircularProgressIndicator(), - ); - } - - if (hasError) { - return const Text("Something went wrong"); - } - - if ((_list ?? []).isEmpty) { - return Center( - child: Text( - "No stream found", - style: Theme.of(context).textTheme.bodyLarge, - ), - ); - } - - final filteredList = (_list ?? []).where((item) { - if (item.streamSource == null || selectedAddonFilter == null) { - return true; - } - - return item.streamSource!.id == selectedAddonFilter; - }).toList(); - - return ListView.builder( - itemBuilder: (context, index) { - if (index == 0) { - return SizedBox( - height: 42, - width: double.infinity, - child: Padding( - padding: const EdgeInsets.only( - left: 12.0, - right: 12.0, - ), - child: ListView( - scrollDirection: Axis.horizontal, - children: [ - for (final value in _sources.values) - Padding( - padding: const EdgeInsets.only(right: 8.0), - child: ChoiceChip( - selected: value.id == selectedAddonFilter, - label: Text(value.title), - onSelected: (i) { - setState(() { - selectedAddonFilter = i ? value.id : null; - }); - }, - ), - ), - ], - ), - ), - ); - } - - final item = filteredList[index - 1]; - - return ListTile( - title: Text(item.title), - subtitle: item.description == null && item.streamSource == null - ? null - : Text( - "${item.description ?? ""}\n---\n${item.streamSource?.title ?? ""}" - .trim(), - ), - trailing: (item.source is MediaURLSource) - ? _buildDownloadButton( - context, - (item.source as MediaURLSource).url, - item.title, - ) - : null, - onTap: () { - if (widget.shouldPop) { - Navigator.of(context).pop(item.source); - - return; - } - - PlaybackConfig config = getPlaybackConfig(); - - if (config.externalPlayer) { - if (!kIsWeb) { - if (item.source is URLSource || item.source is TorrentSource) { - if (config.externalPlayer && Platform.isAndroid) { - openVideoUrlInExternalPlayerAndroid( - videoUrl: (item.source as URLSource).url, - playerPackage: config.currentPlayerPackage, - ); - return; - } - } - } - } - - final meta = (widget.id as Meta).copyWith(); - - Navigator.of(context).push( - MaterialPageRoute( - builder: (ctx) => DocViewer( - source: item.source, - service: widget.service, - meta: meta, - progress: widget.progress, - ), - ), - ); - }, - ); - }, - itemCount: filteredList.length + 1, - ); - } -} - -String calculateHash(String url) { - return url.hashCode.toString(); -} diff --git a/lib/features/connections/widget/stremio/stremio_card.dart b/lib/features/connections/widget/stremio/stremio_card.dart deleted file mode 100644 index 5503813..0000000 --- a/lib/features/connections/widget/stremio/stremio_card.dart +++ /dev/null @@ -1,461 +0,0 @@ -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:cached_network_image_platform_interface/cached_network_image_platform_interface.dart'; -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import 'package:intl/intl.dart'; -import 'package:madari_client/features/connection/types/stremio.dart'; -import 'package:madari_client/features/connections/service/base_connection_service.dart'; - -class StremioCard extends StatefulWidget { - final LibraryItem item; - final String prefix; - final String connectionId; - final BaseConnectionService service; - - const StremioCard({ - super.key, - required this.item, - required this.prefix, - required this.connectionId, - required this.service, - }); - - @override - State createState() => _StremioCardState(); -} - -class _StremioCardState extends State { - @override - Widget build(BuildContext context) { - final meta = widget.item as Meta; - - return Card( - margin: const EdgeInsets.only(right: 8), - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: InkWell( - borderRadius: BorderRadius.circular(12), - onTap: () { - context.push( - "/info/stremio/${widget.connectionId}/${meta.type}/${meta.id}?hero=${widget.prefix}${meta.type}${widget.item.id}", - extra: { - 'meta': meta, - 'service': widget.service, - }, - ); - }, - child: ((meta.currentVideo == null || meta.progress != null) || - (meta.forceRegular == true)) - ? _buildRegular(context, meta) - : _buildWideCard(context, meta), - ), - ), - ); - } - - _buildWideCard(BuildContext context, Meta meta) { - return WideCardStremio(meta: meta); - } - - String? getBackgroundImage(Meta meta) { - String? backgroundImage; - - if (meta.currentVideo != null) { - return meta.currentVideo?.thumbnail ?? meta.poster; - } - - if (meta.poster != null) { - backgroundImage = meta.poster; - } - - return backgroundImage; - } - - _buildRegular(BuildContext context, Meta meta) { - final backgroundImage = - meta.poster ?? meta.logo ?? getBackgroundImage(meta); - - return Hero( - tag: "${widget.prefix}${meta.type}${widget.item.id}", - child: (backgroundImage == null) - ? Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.end, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Expanded( - child: Center( - child: Icon( - Icons.image_not_supported, - size: 26, - ), - ), - ), - Container( - color: Colors.grey, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - meta.name ?? "No title", - style: - Theme.of(context).textTheme.labelMedium?.copyWith( - color: Colors.black54, - fontWeight: FontWeight.w600, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ), - ], - ), - ) - : Stack( - children: [ - Positioned.fill( - child: Container( - decoration: BoxDecoration( - image: DecorationImage( - image: CachedNetworkImageProvider( - "https://proxy-image.syncws.com/insecure/plain/${Uri.encodeQueryComponent(backgroundImage)}@webp", - imageRenderMethodForWeb: - ImageRenderMethodForWeb.HttpGet, - ), - fit: BoxFit.cover, - ), - ), - child: meta.imdbRating != "" - ? Align( - alignment: Alignment.topRight, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Container( - decoration: BoxDecoration( - color: Colors.black54, - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.star, - color: Colors.amber, - size: 16, - ), - const SizedBox(width: 4), - Text( - meta.imdbRating, - style: const TextStyle( - color: Colors.white, - fontSize: 12, - ), - ), - ], - ), - ), - ), - ) - : const SizedBox.shrink(), - ), - ), - if (meta.progress != null) - const Positioned.fill( - child: IconButton( - onPressed: null, - icon: Icon( - Icons.play_arrow, - size: 24, - ), - ), - ), - if (meta.progress != null) - Positioned( - bottom: 0, - left: 0, - right: 0, - child: LinearProgressIndicator( - value: meta.progress! / 100, - minHeight: 5, - ), - ), - if (meta.currentVideo != null) - Positioned( - bottom: 0, - left: 0, - right: 0, - child: Container( - decoration: const BoxDecoration( - gradient: LinearGradient( - colors: [ - Colors.grey, - Colors.transparent, - ], - begin: Alignment.bottomLeft, - end: Alignment.topRight, - ), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: 4, horizontal: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Text( - meta.name ?? "", - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context) - .textTheme - .bodySmall - ?.copyWith(fontWeight: FontWeight.w600), - ), - Text( - "S${meta.currentVideo?.season} E${meta.currentVideo?.episode}", - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith(fontWeight: FontWeight.w600), - ), - ], - ), - ), - ), - ) - ], - ), - ); - } -} - -class WideCardStremio extends StatefulWidget { - final Meta meta; - final Video? video; - - const WideCardStremio({ - super.key, - required this.meta, - this.video, - }); - - @override - State createState() => _WideCardStremioState(); -} - -class _WideCardStremioState extends State { - bool hasErrorWhileLoading = false; - - bool get isInFuture { - final video = widget.video ?? widget.meta.currentVideo; - return video != null && - video.firstAired != null && - video.firstAired!.isAfter(DateTime.now()); - } - - @override - Widget build(BuildContext context) { - if (widget.meta.background == null) { - return Container(); - } - - final video = widget.video ?? widget.meta.currentVideo; - - return Container( - decoration: BoxDecoration( - image: DecorationImage( - image: CachedNetworkImageProvider( - "https://proxy-image.syncws.com/insecure/plain/${Uri.encodeQueryComponent( - hasErrorWhileLoading - ? widget.meta.background! - : (widget.meta.currentVideo?.thumbnail ?? - widget.meta.background!), - )}@webp", - errorListener: (error) { - setState(() { - hasErrorWhileLoading = true; - }); - }, - imageRenderMethodForWeb: ImageRenderMethodForWeb.HttpGet, - ), - fit: BoxFit.cover, - ), - ), - child: Stack( - children: [ - if (isInFuture) - Positioned.fill( - child: Container( - decoration: const BoxDecoration( - gradient: LinearGradient( - colors: [ - Colors.black, - Colors.black54, - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - ), - ), - ), - Positioned.fill( - child: Container( - decoration: const BoxDecoration( - gradient: LinearGradient( - colors: [ - Colors.black, - Colors.transparent, - ], - begin: Alignment.bottomLeft, - end: Alignment.center, - ), - ), - ), - ), - Positioned( - bottom: 0, - child: Padding( - padding: const EdgeInsets.all(12.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Text( - "${widget.meta.name}", - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox( - height: 4, - ), - Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(4), - ), - padding: const EdgeInsets.symmetric(horizontal: 4), - child: Text( - "S${video?.season} E${video?.episode}", - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: Colors.black, - ), - ), - ), - Text( - "${video?.name ?? video?.title}".trim(), - style: Theme.of(context).textTheme.headlineSmall, - ), - ], - ), - ), - ), - if (isInFuture) - Padding( - padding: const EdgeInsets.all(12.0), - child: Text( - getRelativeDate(video!.firstAired!), - style: Theme.of(context).textTheme.bodyLarge, - ), - ), - if (isInFuture) - const Positioned( - bottom: 0, - right: 0, - child: Padding( - padding: EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - child: Column( - children: [ - Padding( - padding: EdgeInsets.symmetric( - horizontal: 4, - vertical: 10, - ), - child: Icon( - Icons.calendar_month, - ), - ), - ], - ), - ), - ), - const Positioned( - child: Center( - child: IconButton.filled( - onPressed: null, - icon: Icon( - Icons.play_arrow, - size: 24, - ), - ), - ), - ), - widget.meta.imdbRating != "" && widget.video == null - ? Align( - alignment: Alignment.topRight, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Container( - decoration: BoxDecoration( - color: Colors.black54, - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.star, - color: Colors.amber, - size: 16, - ), - const SizedBox(width: 4), - Text( - widget.meta.imdbRating, - style: const TextStyle( - color: Colors.white, - fontSize: 12, - ), - ), - ], - ), - ), - ), - ) - : const SizedBox.shrink(), - ], - ), - ); - } -} - -String getRelativeDate(DateTime date) { - final now = DateTime.now(); - final today = DateTime(now.year, now.month, now.day); - final tomorrow = DateTime(now.year, now.month, now.day + 1); - - final difference = date.difference(today).inDays; - - if (date.isAtSameMomentAs(today)) { - return "It's today!"; - } else if (date.isAtSameMomentAs(tomorrow)) { - return "Coming up tomorrow!"; - } else if (difference > 1 && difference < 7) { - return "Coming up in $difference days"; - } else if (difference >= 7 && difference < 14) { - return "Coming up next ${DateFormat('EEEE').format(date)}"; - } else { - return "On ${DateFormat('MM/dd/yyyy').format(date)}"; - } -} diff --git a/lib/features/connections/widget/stremio/stremio_create.dart b/lib/features/connections/widget/stremio/stremio_create.dart deleted file mode 100644 index 625df5d..0000000 --- a/lib/features/connections/widget/stremio/stremio_create.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:flutter/material.dart'; - -class StremioCreateConnection extends StatelessWidget { - const StremioCreateConnection({ - super.key, - }); - - @override - Widget build(BuildContext context) { - return Container(); - } -} diff --git a/lib/features/connections/widget/stremio/stremio_filter.dart b/lib/features/connections/widget/stremio/stremio_filter.dart deleted file mode 100644 index 633b8e0..0000000 --- a/lib/features/connections/widget/stremio/stremio_filter.dart +++ /dev/null @@ -1,130 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:madari_client/features/connection/services/stremio_service.dart'; - -import '../../service/base_connection_service.dart'; - -typedef FilterCallback = void Function(List item); - -class InlineFilters extends StatefulWidget { - final List> filters; - final FilterCallback filterCallback; - - const InlineFilters({ - super.key, - required this.filters, - required this.filterCallback, - }); - - @override - State createState() => _InlineFiltersState(); -} - -class _InlineFiltersState extends State { - final Map _selectedValues = {}; - - List generateFilterItem() { - final List items = []; - - for (final item in _selectedValues.keys) { - items.add( - ConnectionFilterItem(title: item, value: _selectedValues[item]!), - ); - } - - return items; - } - - onChange() { - widget.filterCallback(generateFilterItem()); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return SizedBox( - height: 36, - child: ListView( - scrollDirection: Axis.horizontal, - children: widget.filters - .where((filter) => filter.type == ConnectionFilterType.options) - .map((filter) { - final isSelected = _selectedValues.containsKey(filter.title); - - return Center( - child: Padding( - padding: const EdgeInsets.only(left: 8), - child: InputChip( - label: Text( - (isSelected ? _selectedValues[filter.title] : filter.title) - .toString() - .capitalize(), - style: TextStyle( - fontSize: 14, - color: theme.textTheme.bodyMedium?.color, - ), - ), - selected: isSelected, - onPressed: () { - if (isSelected) { - setState(() { - _selectedValues.remove(filter.title); - }); - - onChange(); - } else { - _showOptionsDialog(filter); - } - }, - deleteIcon: isSelected - ? const Icon( - Icons.close, - ) - : null, - onDeleted: isSelected - ? () { - setState(() { - _selectedValues.remove(filter.title); - - onChange(); - }); - } - : null, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - ), - ), - ); - }).toList(), - ), - ); - } - - void _showOptionsDialog(ConnectionFilter filter) async { - final selectedValue = await showDialog( - context: context, - builder: (BuildContext context) { - return SimpleDialog( - title: Text(filter.title), - children: (filter.values ?? []).map((value) { - return SimpleDialogOption( - onPressed: () { - Navigator.pop(context, value); - }, - child: Text(value.toString()), - ); - }).toList(), - ); - }, - ); - - if (selectedValue != null) { - setState(() { - _selectedValues[filter.title] = selectedValue; - }); - - onChange(); - } - } -} diff --git a/lib/features/connections/widget/stremio/stremio_item_viewer.dart b/lib/features/connections/widget/stremio/stremio_item_viewer.dart deleted file mode 100644 index 109ca77..0000000 --- a/lib/features/connections/widget/stremio/stremio_item_viewer.dart +++ /dev/null @@ -1,520 +0,0 @@ -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:cached_network_image_platform_interface/cached_network_image_platform_interface.dart'; -import 'package:flutter/material.dart'; -import 'package:madari_client/features/connections/service/base_connection_service.dart'; -import 'package:madari_client/features/connections/widget/base/render_stream_list.dart'; -import 'package:madari_client/features/connections/widget/stremio/stremio_season_selector.dart'; -import 'package:url_launcher/url_launcher.dart'; - -import '../../types/stremio/stremio_base.types.dart'; - -class StremioItemViewer extends StatefulWidget { - final Meta? meta; - final Meta? original; - final String? hero; - final BaseConnectionService? service; - final num? progress; - - const StremioItemViewer({ - super.key, - this.meta, - this.original, - this.hero, - this.service, - this.progress, - }); - - @override - State createState() => _StremioItemViewerState(); -} - -class _StremioItemViewerState extends State { - String? _errorMessage; - - @override - void initState() { - super.initState(); - } - - bool get _isLoading { - return widget.original == null; - } - - Meta? _item; - - Meta? get item { - return _item ?? widget.meta; - } - - void _onPlayPressed(BuildContext context) { - if (item == null) { - return; - } - - showModalBottomSheet( - context: context, - builder: (context) { - return Scaffold( - appBar: AppBar( - leading: IconButton( - onPressed: () { - Navigator.of(context).pop(); - }, - icon: const Icon(Icons.close), - ), - title: const Text("Streams"), - ), - body: widget.service == null - ? const Center( - child: CircularProgressIndicator(), - ) - : RenderStreamList( - service: widget.service!, - id: widget.meta as LibraryItem, - shouldPop: false, - ), - ); - }, - ); - } - - @override - Widget build(BuildContext context) { - final screenWidth = MediaQuery.of(context).size.width; - final isWideScreen = screenWidth > 900; - final contentWidth = isWideScreen ? 900.0 : screenWidth; - - if (_errorMessage != null) { - return Text("Failed $_errorMessage"); - } - - if (item == null) { - return const Center( - child: CircularProgressIndicator(), - ); - } - - return Scaffold( - body: SafeArea( - child: CustomScrollView( - slivers: [ - SliverAppBar( - expandedHeight: isWideScreen ? 600 : 500, - pinned: true, - bottom: PreferredSize( - preferredSize: const Size.fromHeight(40), - child: Container( - width: double.infinity, - color: Colors.black, - padding: EdgeInsets.symmetric( - horizontal: - isWideScreen ? (screenWidth - contentWidth) / 2 : 16, - vertical: 16, - ), - child: Row( - children: [ - Expanded( - child: Text( - (item!.name ?? "No name"), - style: Theme.of(context).textTheme.titleLarge, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 16), - ElevatedButton.icon( - icon: _isLoading - ? Container( - margin: const EdgeInsets.only(right: 6), - child: const SizedBox( - width: 12, - height: 12, - child: CircularProgressIndicator(), - ), - ) - : const Icon( - Icons.play_arrow_rounded, - size: 24, - color: Colors.black87, - ), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.white, - ), - onPressed: () { - if (item!.type == "series" && _isLoading) { - return; - } - - _onPlayPressed(context); - }, - label: Text( - widget.progress != null && widget.progress != 0 - ? "Resume" - : "Play", - style: Theme.of(context) - .primaryTextTheme - .bodyMedium - ?.copyWith( - color: Colors.black87, - ), - ), - ), - ], - ), - ), - ), - flexibleSpace: FlexibleSpaceBar( - background: Stack( - fit: StackFit.expand, - children: [ - if (item!.background != null) - Image.network( - item!.background!, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - if (item!.poster == null) { - return Container(); - } - return Image.network(item!.poster!, - fit: BoxFit.cover); - }, - ), - DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.transparent, - Colors.black.withOpacity(0.8), - ], - ), - ), - ), - Positioned( - bottom: 86, - left: 16, - right: 16, - child: Container( - padding: EdgeInsets.symmetric( - horizontal: isWideScreen - ? (screenWidth - contentWidth) / 2 - : 16, - vertical: 16, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Hero( - tag: "${widget.hero}", - child: Container( - width: 150, - height: 225, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - image: item!.poster == null - ? null - : DecorationImage( - image: NetworkImage(item!.poster!), - fit: BoxFit.cover, - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.3), - spreadRadius: 2, - blurRadius: 8, - ), - ], - ), - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - if (item!.year != null) - Chip( - label: Text("${item!.year ?? ""}"), - backgroundColor: Colors.white24, - labelStyle: const TextStyle( - color: Colors.white), - ), - const SizedBox(width: 8), - if (item!.imdbRating != null) - Row( - children: [ - const Icon( - Icons.star, - color: Colors.amber, - size: 20, - ), - const SizedBox(width: 4), - Text( - item!.imdbRating!, - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith( - color: Colors.white), - ), - ], - ), - ], - ), - ], - ), - ), - ], - ), - ), - ), - ], - ), - ), - ), - if (widget.original != null && - widget.original?.type == "series" && - widget.original?.videos?.isNotEmpty == true) - StremioItemSeasonSelector( - meta: (item as Meta).copyWith( - selectedVideoIndex: widget.meta?.selectedVideoIndex, - ), - service: widget.service, - ), - SliverPadding( - padding: EdgeInsets.symmetric( - horizontal: - isWideScreen ? (screenWidth - contentWidth) / 2 : 16, - vertical: 16, - ), - sliver: SliverList( - delegate: SliverChildListDelegate([ - if (widget.original != null) - const SizedBox( - height: 12, - ), - Text( - 'Description', - style: Theme.of(context).textTheme.titleLarge, - ), - if (item!.description != null) const SizedBox(height: 8), - if (item!.description != null) - Text( - item!.description!, - style: Theme.of(context).textTheme.bodyMedium, - ), - const SizedBox(height: 16), - - // Additional Details - _buildDetailSection(context, 'Additional Information', [ - if (item!.genre != null) - _buildDetailRow('Genres', item!.genre!.join(', ')), - if (item!.country != null) - _buildDetailRow('Country', item!.country!), - if (item!.runtime != null) - _buildDetailRow('Runtime', item!.runtime!), - if (item!.language != null) - _buildDetailRow('Language', item!.language!), - ]), - - // Cast - if (item!.creditsCast != null && - item!.creditsCast!.isNotEmpty) - _buildCastSection(context, item!.creditsCast!), - - // Cast - if (item!.creditsCrew != null && - item!.creditsCrew!.isNotEmpty) - _buildCastSection( - context, - title: "Crew", - item!.creditsCrew!.map((item) { - return CreditsCast( - character: item.department, - name: item.name, - profilePath: item.profilePath, - id: item.id, - ); - }).toList(), - ), - - // Trailers - if (item!.trailerStreams != null && - item!.trailerStreams!.isNotEmpty) - _buildTrailersSection(context, item!.trailerStreams!), - ]), - ), - ), - ], - ), - ), - ); - } - - Widget _buildDetailSection( - BuildContext context, String title, List details) { - if (details.isEmpty) return const SizedBox.shrink(); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 8), - ...details, - const SizedBox(height: 16), - ], - ); - } - - Widget _buildDetailRow(String label, String value) { - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 120, - child: Text( - '$label:', - style: const TextStyle(fontWeight: FontWeight.bold), - ), - ), - Expanded(child: Text(value)), - ], - ), - ); - } - - Widget _buildCastSection( - BuildContext context, - List cast, { - String title = "Cast", - }) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 8), - SizedBox( - height: 150, - child: ListView.builder( - scrollDirection: Axis.horizontal, - itemCount: cast.length, - itemBuilder: (context, index) { - final actor = cast[index]; - return Padding( - padding: const EdgeInsets.only(right: 16), - child: Column( - children: [ - CircleAvatar( - radius: 50, - backgroundImage: actor.profilePath != null - ? CachedNetworkImageProvider( - actor.profilePath!.startsWith("/") - ? "https://proxy-image.syncws.com/insecure/plain/${Uri.encodeQueryComponent("https://image.tmdb.org/t/p/original/${actor.profilePath}")}@webp" - : actor.profilePath!, - imageRenderMethodForWeb: - ImageRenderMethodForWeb.HttpGet, - ) - : null, - child: actor.profilePath == null - ? Icon( - Icons.person, - size: 50, - color: Colors.grey[300], - ) - : null, - ), - const SizedBox(height: 8), - Text( - actor.name, - style: Theme.of(context).textTheme.bodyMedium, - ), - Text( - actor.character, - style: Theme.of(context).textTheme.bodySmall, - ), - ], - ), - ); - }, - ), - ), - const SizedBox(height: 16), - ], - ); - } - - Widget _buildTrailersSection( - BuildContext context, List trailers) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Trailers', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 8), - SizedBox( - height: 100, - child: ListView.builder( - scrollDirection: Axis.horizontal, - itemCount: trailers.length, - itemBuilder: (context, index) { - final trailer = trailers[index]; - - return GestureDetector( - onTap: () async { - final url = Uri.parse( - "https://www.youtube-nocookie.com/embed/${trailer.ytId}?autoplay=1&color=red&disablekb=1&enablejsapi=1&fs=1", - ); - - launchUrl( - url, - ); - }, - child: Padding( - padding: const EdgeInsets.only(right: 16), - child: Container( - width: 160, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - color: Colors.black26, - image: DecorationImage( - image: CachedNetworkImageProvider( - "https://proxy-image.syncws.com/insecure/plain/${Uri.encodeQueryComponent("https://i.ytimg.com/vi/${trailer.ytId}/mqdefault.jpg")}@webp", - imageRenderMethodForWeb: - ImageRenderMethodForWeb.HttpGet, - ), - fit: BoxFit.contain, - ), - ), - child: Center( - child: Text( - trailer.title, - textAlign: TextAlign.center, - style: const TextStyle(color: Colors.white), - ), - ), - ), - ), - ); - }, - ), - ), - const SizedBox( - height: 12, - ), - ], - ); - } -} diff --git a/lib/features/connections/widget/stremio/stremio_item_viewer_card.dart b/lib/features/connections/widget/stremio/stremio_item_viewer_card.dart deleted file mode 100644 index 4ad1c17..0000000 --- a/lib/features/connections/widget/stremio/stremio_item_viewer_card.dart +++ /dev/null @@ -1,476 +0,0 @@ -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:cached_network_image_platform_interface/cached_network_image_platform_interface.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:madari_client/features/connections/service/base_connection_service.dart'; -import 'package:madari_client/features/connections/widget/base/render_stream_list.dart'; -import 'package:url_launcher/url_launcher.dart'; - -import '../../types/stremio/stremio_base.types.dart'; - -class StremioItemViewerTV extends StatefulWidget { - final Meta? meta; - final Meta? original; - final String? hero; - final BaseConnectionService? service; - final String library; - - const StremioItemViewerTV({ - super.key, - this.meta, - this.original, - this.hero, - this.service, - required this.library, - }); - - @override - State createState() => _StremioItemViewerTVState(); -} - -class _StremioItemViewerTVState extends State { - String? _errorMessage; - final FocusNode _playButtonFocusNode = FocusNode(); - final FocusNode _trailersFocusNode = FocusNode(); - bool _showTrailers = false; - - @override - void initState() { - super.initState(); - // Set initial focus to the Play button - _playButtonFocusNode.requestFocus(); - } - - @override - void dispose() { - _playButtonFocusNode.dispose(); - _trailersFocusNode.dispose(); - super.dispose(); - } - - bool get _isLoading { - return widget.original == null; - } - - Meta? _item; - - Meta? get item { - return _item ?? widget.meta; - } - - void _onPlayPressed(BuildContext context) { - if (item == null) { - return; - } - - showModalBottomSheet( - context: context, - builder: (context) { - return Scaffold( - appBar: AppBar( - leading: IconButton( - onPressed: () { - Navigator.of(context).pop(); - }, - icon: const Icon(Icons.close), - ), - title: const Text("Streams"), - ), - body: widget.service == null - ? const Center( - child: CircularProgressIndicator(), - ) - : RenderStreamList( - service: widget.service!, - id: widget.meta as LibraryItem, - shouldPop: false, - ), - ); - }, - ); - } - - @override - Widget build(BuildContext context) { - if (_errorMessage != null) { - return Center( - child: Text("Failed $_errorMessage"), - ); - } - - if (item == null) { - return const Center( - child: CircularProgressIndicator(), - ); - } - - return Scaffold( - body: Stack( - children: [ - // Static Background - if (item!.background != null) - Positioned.fill( - child: Image.network( - item!.background!, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - if (item!.poster == null) { - return Container(); - } - return Image.network(item!.poster!, fit: BoxFit.cover); - }, - ), - ), - // Gradient Overlay - Positioned.fill( - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.transparent, - Colors.black.withOpacity(0.8), - ], - ), - ), - ), - ), - // Content - SafeArea( - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - // Title - Padding( - padding: const EdgeInsets.all(16.0), - child: Text( - item!.name ?? "No Title", - style: Theme.of(context).textTheme.headlineMedium?.copyWith( - color: Colors.white, - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, - ), - ), - // Poster and Details Section - Expanded( - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: 900, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Poster - Hero( - tag: "${widget.hero}", - child: Container( - width: 150, - height: 225, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - image: item!.poster == null - ? null - : DecorationImage( - image: - NetworkImage(item!.poster!), - fit: BoxFit.cover, - ), - boxShadow: [ - BoxShadow( - color: - Colors.black.withOpacity(0.3), - spreadRadius: 2, - blurRadius: 8, - ), - ], - ), - ), - ), - const SizedBox(width: 16), - // Details - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - // Year and Rating - Row( - children: [ - if (item!.year != null) - Chip( - label: - Text("${item!.year ?? ""}"), - backgroundColor: Colors.white24, - labelStyle: const TextStyle( - color: Colors.white), - ), - const SizedBox(width: 8), - if (item!.imdbRating != "") - Row( - children: [ - const Icon( - Icons.star, - color: Colors.amber, - size: 20, - ), - const SizedBox(width: 4), - Text( - item!.imdbRating, - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith( - color: - Colors.white), - ), - ], - ), - ], - ), - const SizedBox(height: 16), - // Description - Text( - 'Description', - style: Theme.of(context) - .textTheme - .titleLarge, - ), - if (item!.description != null) - const SizedBox(height: 8), - if (item!.description != null) - Text( - item!.description!, - style: Theme.of(context) - .textTheme - .bodyMedium, - ), - const SizedBox(height: 16), - // Additional Details - _buildDetailSection( - context, 'Additional Information', [ - if (item!.genre != null) - _buildDetailRow('Genres', - item!.genre!.join(', ')), - if (item!.country != null) - _buildDetailRow( - 'Country', item!.country!), - if (item!.runtime != null) - _buildDetailRow( - 'Runtime', item!.runtime!), - if (item!.language != null) - _buildDetailRow( - 'Language', item!.language!), - ]), - ], - ), - ), - ], - ), - const SizedBox(height: 16), - // Play Button - Focus( - focusNode: _playButtonFocusNode, - onKey: (node, event) { - if (event is RawKeyDownEvent) { - if (event.logicalKey == - LogicalKeyboardKey.arrowDown) { - // Show Trailers - setState(() { - _showTrailers = true; - }); - FocusScope.of(context) - .requestFocus(_trailersFocusNode); - return KeyEventResult.handled; - } else if (event.logicalKey == - LogicalKeyboardKey.enter) { - // Play the item - _onPlayPressed(context); - return KeyEventResult.handled; - } - } - return KeyEventResult.ignored; - }, - child: ElevatedButton.icon( - icon: _isLoading - ? Container( - margin: - const EdgeInsets.only(right: 6), - child: const SizedBox( - width: 12, - height: 12, - child: CircularProgressIndicator(), - ), - ) - : const Icon( - Icons.play_arrow_rounded, - size: 24, - color: Colors.black87, - ), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.white, - ), - onPressed: () { - if (item!.type == "series" && _isLoading) { - return; - } - - _onPlayPressed(context); - }, - label: Text( - "Play", - style: Theme.of(context) - .primaryTextTheme - .bodyMedium - ?.copyWith( - color: Colors.black87, - ), - ), - ), - ), - ], - ), - ), - ), - ), - ), - ), - if (_showTrailers && - item!.trailerStreams != null && - item!.trailerStreams!.isNotEmpty) - Focus( - focusNode: _trailersFocusNode, - onKey: (node, event) { - if (event is RawKeyDownEvent) { - if (event.logicalKey == LogicalKeyboardKey.arrowUp) { - // Hide Trailers and move focus back to Play Button - setState(() { - _showTrailers = false; - }); - FocusScope.of(context) - .requestFocus(_playButtonFocusNode); - return KeyEventResult.handled; - } - } - return KeyEventResult.ignored; - }, - child: - _buildTrailersSection(context, item!.trailerStreams!), - ), - ], - ), - ), - ], - ), - ); - } - - Widget _buildDetailSection( - BuildContext context, String title, List details) { - if (details.isEmpty) return const SizedBox.shrink(); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 8), - ...details, - const SizedBox(height: 16), - ], - ); - } - - Widget _buildDetailRow(String label, String value) { - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 120, - child: Text( - '$label:', - style: const TextStyle(fontWeight: FontWeight.bold), - ), - ), - Expanded(child: Text(value)), - ], - ), - ); - } - - Widget _buildTrailersSection( - BuildContext context, List trailers) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Trailers', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 8), - SizedBox( - height: 100, - child: ListView.builder( - scrollDirection: Axis.horizontal, - itemCount: trailers.length, - itemBuilder: (context, index) { - final trailer = trailers[index]; - - return GestureDetector( - onTap: () async { - final url = Uri.parse( - "https://www.youtube-nocookie.com/embed/${trailer.ytId}?autoplay=1&color=red&disablekb=1&enablejsapi=1&fs=1", - ); - - launchUrl( - url, - ); - }, - child: Padding( - padding: const EdgeInsets.only(right: 16), - child: Container( - width: 160, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - color: Colors.black26, - image: DecorationImage( - image: CachedNetworkImageProvider( - "https://proxy-image.syncws.com/insecure/plain/${Uri.encodeQueryComponent("https://i.ytimg.com/vi/${trailer.ytId}/mqdefault.jpg")}@webp", - imageRenderMethodForWeb: - ImageRenderMethodForWeb.HttpGet, - ), - fit: BoxFit.contain, - ), - ), - child: Center( - child: Text( - trailer.title, - textAlign: TextAlign.center, - style: const TextStyle(color: Colors.white), - ), - ), - ), - ), - ); - }, - ), - ), - ], - ), - ); - } -} diff --git a/lib/features/connections/widget/stremio/stremio_list_item.dart b/lib/features/connections/widget/stremio/stremio_list_item.dart deleted file mode 100644 index 796179c..0000000 --- a/lib/features/connections/widget/stremio/stremio_list_item.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../service/base_connection_service.dart'; - -class StremioListItem extends StatelessWidget { - final LibraryItem item; - - const StremioListItem({ - super.key, - required this.item, - }); - - @override - Widget build(BuildContext context) { - return const ListTile(); - } -} diff --git a/lib/features/connections/widget/stremio/stremio_season_selector.dart b/lib/features/connections/widget/stremio/stremio_season_selector.dart deleted file mode 100644 index dd7c46b..0000000 --- a/lib/features/connections/widget/stremio/stremio_season_selector.dart +++ /dev/null @@ -1,473 +0,0 @@ -import 'dart:math'; - -import 'package:flutter/material.dart'; -import 'package:intl/intl.dart' as intl; -import 'package:madari_client/features/connection/types/stremio.dart'; -import 'package:madari_client/features/connections/service/base_connection_service.dart'; -import 'package:madari_client/features/connections/widget/base/render_stream_list.dart'; -import 'package:madari_client/features/trakt/service/trakt.service.dart'; -import 'package:madari_client/utils/common.dart'; - -import '../../../doc_viewer/types/doc_source.dart'; -import '../../../watch_history/service/base_watch_history.dart'; -import '../../../watch_history/service/zeee_watch_history.dart'; - -class StremioItemSeasonSelector extends StatefulWidget { - final Meta meta; - final int? season; - final BaseConnectionService? service; - final bool shouldPop; - - const StremioItemSeasonSelector({ - super.key, - required this.meta, - this.season, - required this.service, - this.shouldPop = false, - }); - - @override - State createState() => - _StremioItemSeasonSelectorState(); -} - -class _StremioItemSeasonSelectorState extends State - with SingleTickerProviderStateMixin { - int? selectedSeason; - late TabController? _tabController; - late final Map> seasonMap; - final zeeeWatchHistory = ZeeeWatchHistoryStatic.service; - - late Meta meta = widget.meta; - - final Map _progress = {}; - - @override - void initState() { - super.initState(); - - seasonMap = _organizeEpisodes(); - - if (seasonMap.keys.isEmpty) { - return; - } - - final index = getSelectedSeason(); - - _tabController = TabController( - length: seasonMap.keys.length, - vsync: this, - initialIndex: index.clamp( - 0, - seasonMap.keys.isNotEmpty ? seasonMap.keys.length - 1 : 0, - ), - ); - - // This is for rendering the component again for the selection of another tab - _tabController!.addListener(() { - setState(() {}); - }); - - getWatchHistory(); - } - - int getSelectedSeason() { - return widget.meta.currentVideo?.season ?? - widget.meta.videos?.lastWhereOrNull((item) { - return item.progress != null; - })?.season ?? - widget.season ?? - 0; - } - - getWatchHistory() async { - final traktService = TraktService.instance; - - try { - if (TraktService.isEnabled()) { - final result = await traktService!.getProgress( - widget.meta, - bypassCache: false, - ); - - setState(() { - meta = result; - }); - - final index = getSelectedSeason(); - _tabController?.animateTo(index); - - return; - } - } catch (e, stack) { - print(e); - print(stack); - print("Unable to get trakt progress"); - } - - final docs = await zeeeWatchHistory!.getItemWatchHistory( - ids: widget.meta.videos!.map((item) { - return WatchHistoryGetRequest( - id: item.id, - episode: item.episode.toString(), - season: item.season.toString(), - ); - }).toList(), - ); - - for (var item in docs) { - _progress[item.id] = item.progress.toDouble(); - } - - final index = getSelectedSeason(); - - _tabController?.animateTo(index); - } - - @override - void dispose() { - _tabController?.dispose(); - super.dispose(); - } - - Map> _organizeEpisodes() { - final episodes = meta.videos ?? []; - return groupBy(episodes, (Video video) => video.season); - } - - void openEpisode({ - required int index, - }) async { - if (widget.service == null) { - return; - } - final onClose = showModalBottomSheet( - context: context, - builder: (context) { - final meta = this.meta.copyWith( - selectedVideoIndex: index, - ); - - return Scaffold( - appBar: AppBar( - title: Text( - "Streams for S${meta.currentVideo?.season} E${meta.currentVideo?.episode}", - ), - ), - body: RenderStreamList( - service: widget.service!, - id: meta, - shouldPop: widget.shouldPop, - ), - ); - }, - ); - - if (widget.shouldPop) { - final val = await onClose; - - if (val is MediaURLSource && context.mounted && mounted) { - Navigator.pop( - context, - val, - ); - } - - return; - } - - onClose.then((data) { - getWatchHistory(); - }); - } - - @override - Widget build(BuildContext context) { - final seasons = seasonMap.keys.toList()..sort(); - final colorScheme = Theme.of(context).colorScheme; - - final screenWidth = MediaQuery.of(context).size.width; - final isWideScreen = screenWidth > 900; - final contentWidth = isWideScreen ? 900.0 : screenWidth; - - if (_tabController == null) { - return const SliverMainAxisGroup( - slivers: [ - SliverToBoxAdapter( - child: Column( - children: [ - SizedBox( - height: 0, - ) - ], - ), - ), - ], - ); - } - - return SliverMainAxisGroup( - slivers: [ - SliverPadding( - padding: EdgeInsets.symmetric( - horizontal: isWideScreen ? (screenWidth - contentWidth) / 2 : 8, - ), - sliver: SliverToBoxAdapter( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox( - height: 12, - ), - Center( - child: Container( - constraints: const BoxConstraints(maxWidth: 320), - child: ElevatedButton.icon( - icon: const Icon(Icons.shuffle), - label: const Text("Random Episode"), - onPressed: () { - Random random = Random(); - int randomIndex = random.nextInt( - widget.meta.videos!.length, - ); - - openEpisode(index: randomIndex); - }, - ), - ), - ), - Container( - margin: const EdgeInsets.symmetric(horizontal: 16), - decoration: BoxDecoration( - color: colorScheme.surface.withOpacity(0.1), - borderRadius: BorderRadius.circular(8), - ), - child: TabBar( - tabAlignment: TabAlignment.start, - dividerColor: Colors.transparent, - controller: _tabController, - isScrollable: true, - splashBorderRadius: BorderRadius.circular(8), - padding: const EdgeInsets.all(4), - tabs: seasons.map((season) { - return Tab( - text: season == 0 ? "Specials" : 'Season $season', - height: 40, - ); - }).toList(), - ), - ), - const SizedBox(height: 16), - ], - ), - ), - ), - SliverPadding( - padding: EdgeInsets.symmetric( - horizontal: isWideScreen ? (screenWidth - contentWidth) / 2 : 8, - ), - sliver: SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - final currentSeason = seasons[_tabController!.index]; - final episodes = seasonMap[currentSeason]!; - final episode = episodes[index]; - - final videoIndex = meta.videos?.indexOf(episode); - - final progress = ((!TraktService.isEnabled() - ? (_progress[episode.id] ?? 0) / 100 - : videoIndex != -1 - ? (meta.videos![videoIndex!].progress) - : 0.toDouble()) ?? - 0) / - 100; - - return InkWell( - borderRadius: BorderRadius.circular(12), - onTap: () async { - if (videoIndex != null) { - openEpisode( - index: videoIndex, - ); - } - }, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only( - left: 8.0, - top: 8.0, - bottom: 8.0, - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Stack( - children: [ - Container( - child: episode.thumbnail != null && - episode.thumbnail!.isNotEmpty - ? Image.network( - episode.thumbnail!, - width: 140, - height: 90, - fit: BoxFit.cover, - errorBuilder: - (context, error, stackTrace) { - return Container( - width: 140, - height: 90, - color: colorScheme - .surfaceContainerHighest, - child: Icon( - Icons.movie, - color: - colorScheme.onSurfaceVariant, - ), - ); - }, - ) - : Container( - width: 140, - height: 90, - color: - colorScheme.surfaceContainerHighest, - child: Icon( - Icons.movie, - color: colorScheme.onSurfaceVariant, - ), - ), - ), - Positioned( - top: 0, - bottom: 0, - right: 0, - left: 0, - child: Stack( - children: [ - const Center( - child: Icon( - Icons.play_arrow, - ), - ), - Center( - child: CircularProgressIndicator( - value: progress, - ), - ) - ], - ), - ), - if (progress > .9) - Positioned( - bottom: 0, - right: 0, - child: Padding( - padding: const EdgeInsets.all(4.0), - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - color: Colors.teal, - ), - child: Padding( - padding: const EdgeInsets.only( - right: 4.0, - bottom: 2.0, - left: 4.0, - top: 2.0, - ), - child: Center( - child: Text( - "Watched", - style: Theme.of(context) - .textTheme - .bodySmall - ?.copyWith( - color: Colors.black, - ), - ), - ), - ), - ), - ), - ), - ], - ), - ), - ), - const SizedBox(width: 16), - Expanded( - child: Padding( - padding: const EdgeInsets.only( - top: 8.0, - bottom: 8.0, - ), - child: Center( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - '${index + 1}. ${episode.name ?? 'Episode ${episode.episode}'}', - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - if (episode.released != null) ...[ - const SizedBox(height: 4), - Text( - intl.DateFormat('MMMM dd yyyy') - .format(episode.released!), - style: TextStyle( - fontSize: 12, - color: colorScheme.onSurface - .withOpacity(0.7), - ), - ), - ], - if (episode.overview != null) ...[ - Text( - episode.overview!, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 14, - color: colorScheme.onSurface - .withOpacity(0.9), - ), - ), - ], - ], - ), - ), - ), - ), - ], - ), - ); - }, - childCount: - seasonMap[seasons[_tabController!.index]]?.length ?? 0, - ), - ), - ), - ], - ); - } -} - -Map> groupBy(Iterable items, T Function(E) key) { - final map = >{}; - - for (final item in items) { - final keyValue = key(item); - if (!map.containsKey(keyValue)) { - map[keyValue] = []; - } - map[keyValue]!.add(item); - } - - return map; -} diff --git a/lib/features/doc_viewer/container/doc_viewer.dart b/lib/features/doc_viewer/container/doc_viewer.dart deleted file mode 100644 index fa1d9e2..0000000 --- a/lib/features/doc_viewer/container/doc_viewer.dart +++ /dev/null @@ -1,128 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:madari_client/features/connections/service/base_connection_service.dart'; -import 'package:madari_client/features/doc_viewer/container/pdf_viewer.dart'; -import 'package:madari_client/features/doc_viewer/container/photo_viewer.dart'; -import 'package:madari_client/features/doc_viewer/container/video_viewer.dart'; -import 'package:madari_client/features/doc_viewer/types/doc_source.dart'; - -import 'iframe.dart'; - -class DocViewer extends StatefulWidget { - final DocSource source; - - final String? library; - - final LibraryItem? meta; - final String? season; - final BaseConnectionService? service; - - final double? progress; - - const DocViewer({ - super.key, - required this.source, - this.service, - this.library, - this.meta, - this.season, - this.progress, - }); - - @override - State createState() => _DocViewerState(); -} - -class _DocViewerState extends State { - bool isReady = false; - String? _errorMessage; - - @override - void dispose() { - super.dispose(); - widget.source.dispose(); - } - - @override - void initState() { - super.initState(); - - widget.source.init().then((_) { - setState(() { - isReady = true; - }); - }).catchError((err) { - setState(() { - if (mounted) { - _errorMessage = err.toString(); - } - }); - widget.source.dispose(); - }); - } - - @override - Widget build(BuildContext context) { - if (!isReady) { - return Scaffold( - backgroundColor: Colors.black, - appBar: AppBar( - title: Text(widget.source.title), - ), - body: const Center( - child: CircularProgressIndicator(), - ), - ); - } - - if (_errorMessage != null) { - return Text("Error $_errorMessage"); - } - - if (widget.source is IframeSource) { - return IframeViewer( - source: widget.source as IframeSource, - ); - } - - switch (widget.source.getType()) { - case DocType.pdf: - return PDFViewerContainer(source: widget.source); - case DocType.photo: - return PhotoViewer(source: widget.source); - case DocType.video: - return VideoViewer( - source: widget.source, - meta: widget.meta, - service: widget.service, - currentSeason: widget.season, - library: widget.library, - ); - default: - return Scaffold( - extendBody: true, - appBar: AppBar( - title: Text(widget.source.title), - ), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const Icon( - Icons.broken_image, - size: 42, - ), - const SizedBox( - height: 12, - ), - Text( - "Unsupported file ${widget.source.title}", - style: Theme.of(context).textTheme.headlineSmall, - ), - ], - ), - ), - ); - } - } -} diff --git a/lib/features/doc_viewer/container/iframe.dart b/lib/features/doc_viewer/container/iframe.dart deleted file mode 100644 index cb20ce1..0000000 --- a/lib/features/doc_viewer/container/iframe.dart +++ /dev/null @@ -1,130 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_inappwebview/flutter_inappwebview.dart'; -import 'package:madari_client/features/doc_viewer/types/doc_source.dart'; - -class AdblockList { - static String str = ""; -} - -class IframeViewer extends StatefulWidget { - final IframeSource source; - const IframeViewer({ - super.key, - required this.source, - }); - - @override - State createState() => _IframeViewerState(); -} - -class _IframeViewerState extends State { - final List contentBlockers = []; - bool _isFullScreen = false; - - @override - void initState() { - super.initState(); - - final url = AdblockList.str - .split("\n") - .where((item) => item.trim() != "") - .map((item) { - return ".*.$item/.*"; - }).toList(); - - for (final adUrlFilter in url) { - contentBlockers.add( - ContentBlocker( - trigger: ContentBlockerTrigger( - urlFilter: adUrlFilter, - ), - action: ContentBlockerAction( - type: ContentBlockerActionType.BLOCK, - ), - ), - ); - } - - contentBlockers.add( - ContentBlocker( - trigger: ContentBlockerTrigger( - urlFilter: ".*", - ), - action: ContentBlockerAction( - type: ContentBlockerActionType.CSS_DISPLAY_NONE, - selector: """ - .banner, .banners, .ads, .ad, .advert, .advertisement, - [class*="ad-"], [class*="Ad"], [class*="advertisement"], - [id*="google_ads"], [id*="ad-"], - iframe[src*="ads"], iframe[src*="doubleclick"], - div[aria-label*="advertisement"], - .social-share, .newsletter-signup, - .popup, .modal-overlay, .cookie-notice, - [class*="cookie-banner"], [id*="cookie-consent"] - """, - ), - ), - ); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - elevation: 0, - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => Navigator.of(context).pop(), - ), - actions: [ - IconButton( - icon: Icon( - _isFullScreen ? Icons.fullscreen_exit : Icons.fullscreen, - color: Colors.black, - ), - onPressed: () { - setState(() { - _isFullScreen = !_isFullScreen; - }); - if (_isFullScreen) { - SystemChrome.setEnabledSystemUIMode( - SystemUiMode.immersiveSticky); - } else { - SystemChrome.setEnabledSystemUIMode( - SystemUiMode.manual, - overlays: SystemUiOverlay.values, - ); - } - }, - ), - ], - ), - body: InAppWebView( - initialSettings: InAppWebViewSettings( - contentBlockers: contentBlockers, - useShouldOverrideUrlLoading: true, - iframeAllow: - "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture", - iframeCsp: "", - iframeReferrerPolicy: ReferrerPolicy.ORIGIN, - iframeAllowFullscreen: true, - ), - initialUrlRequest: URLRequest( - url: WebUri( - widget.source.url, - ), - ), - ), - ); - } - - @override - void dispose() { - SystemChrome.setEnabledSystemUIMode( - SystemUiMode.manual, - overlays: SystemUiOverlay.values, - ); - super.dispose(); - } -} diff --git a/lib/features/doc_viewer/container/pdf/magic_bottom_sheet.dart b/lib/features/doc_viewer/container/pdf/magic_bottom_sheet.dart deleted file mode 100644 index 9f028ef..0000000 --- a/lib/features/doc_viewer/container/pdf/magic_bottom_sheet.dart +++ /dev/null @@ -1,98 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:pdfrx/pdfrx.dart'; -import 'package:pocketbase/pocketbase.dart'; - -import '../../../../engine/engine.dart'; -import 'magic_page_selector_bottom_sheet.dart'; - -class MagicBottomSheet extends StatefulWidget { - final PdfViewerController controller; - const MagicBottomSheet({ - super.key, - required this.controller, - }); - - @override - State createState() => _MagicBottomSheetState(); -} - -class _MagicBottomSheetState extends State { - final pb = AppEngine.engine.pb; - - late Future> item; - - @override - void initState() { - super.initState(); - - item = pb.collection("ai_action").getList(perPage: 100); - } - - @override - Widget build(BuildContext context) { - return FutureBuilder( - future: item, - builder: (context, snapshot) { - if (snapshot.hasError) { - return Scaffold( - body: Text("Error: ${snapshot.error}"), - ); - } - - if (snapshot.connectionState != ConnectionState.done) { - return const Scaffold( - body: Center( - child: CircularProgressIndicator(), - ), - ); - } - - return Scaffold( - appBar: AppBar( - leading: IconButton( - icon: const Icon(Icons.close), - onPressed: () {}, - ), - title: const Text("AI Actions"), - ), - body: ListView.builder( - itemCount: snapshot.data!.items.length, - itemBuilder: (ctx, index) { - final item = snapshot.data!.items[index]; - final description = item.getStringValue("description"); - - return ListTile( - onTap: () async { - final result = await showModalBottomSheet( - context: context, - builder: (ctx) { - return MagicPageSelectorBottomSheet( - item: item, - controller: widget.controller, - ); - }, - ); - - if (context.mounted && mounted) { - Navigator.pop(context, [item, result]); - } - }, - leading: const Icon(Icons.question_answer_outlined), - title: Text( - snapshot.data!.items[index].getStringValue("title"), - ), - subtitle: description != "" - ? Text( - description, - maxLines: 3, - overflow: TextOverflow.ellipsis, - ) - : null, - ); - }, - ), - ); - }, - ); - } -} diff --git a/lib/features/doc_viewer/container/pdf/magic_page_selector_bottom_sheet.dart b/lib/features/doc_viewer/container/pdf/magic_page_selector_bottom_sheet.dart deleted file mode 100644 index 7f8f85b..0000000 --- a/lib/features/doc_viewer/container/pdf/magic_page_selector_bottom_sheet.dart +++ /dev/null @@ -1,134 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:pdfrx/pdfrx.dart'; -import 'package:pocketbase/pocketbase.dart'; - -class MagicPageSelectorBottomSheet extends StatelessWidget { - final RecordModel item; - final PdfViewerController controller; - - const MagicPageSelectorBottomSheet({ - super.key, - required this.item, - required this.controller, - }); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text("Select 📃"), - ), - body: ListView( - children: [ - ListTile( - leading: const Icon(Icons.file_present), - title: const Text('Current Page'), - subtitle: Text('Page ${controller.pageNumber}'), - onTap: () { - Navigator.pop(context, [controller.pageNumber!]); - }, - ), - ListTile( - leading: const Icon(Icons.filter_frames), - title: const Text('Page Range'), - subtitle: const Text('Select a range of pages'), - onTap: () async { - final RangeValues? result = await showDialog( - context: context, - builder: (BuildContext context) { - return PageRangeDialog( - maxPages: controller.pageCount, - ); - }, - ); - - if (result != null) { - final List pages = List.generate( - (result.end - result.start + 1).toInt(), - (index) => index + result.start.toInt(), - ); - if (context.mounted) Navigator.pop(context, pages); - } - }, - ), - ListTile( - leading: const Icon(Icons.all_inclusive), - title: const Text('All Pages'), - subtitle: Text('Total ${controller.pageCount} pages'), - onTap: () { - final List allPages = List.generate( - controller.pageCount, - (index) => index + 1, - ); - Navigator.pop(context, allPages); - }, - ), - ], - ), - ); - } -} - -// Additional dialog for page range selection -class PageRangeDialog extends StatefulWidget { - final int maxPages; - - const PageRangeDialog({ - super.key, - required this.maxPages, - }); - - @override - State createState() => _PageRangeDialogState(); -} - -class _PageRangeDialogState extends State { - late RangeValues _currentRangeValues; - - @override - void initState() { - super.initState(); - _currentRangeValues = RangeValues(1, widget.maxPages.toDouble()); - } - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: const Text('Select Page Range'), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - RangeSlider( - values: _currentRangeValues, - min: 1, - max: widget.maxPages.toDouble(), - divisions: widget.maxPages - 1, - labels: RangeLabels( - _currentRangeValues.start.round().toString(), - _currentRangeValues.end.round().toString(), - ), - onChanged: (RangeValues values) { - setState(() { - _currentRangeValues = values; - }); - }, - ), - Text( - 'Pages ${_currentRangeValues.start.round()} to ${_currentRangeValues.end.round()}', - style: Theme.of(context).textTheme.bodyMedium, - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.pop(context, _currentRangeValues), - child: const Text('OK'), - ), - ], - ); - } -} diff --git a/lib/features/doc_viewer/container/pdf/magic_show_markdown.dart b/lib/features/doc_viewer/container/pdf/magic_show_markdown.dart deleted file mode 100644 index f1a6d5a..0000000 --- a/lib/features/doc_viewer/container/pdf/magic_show_markdown.dart +++ /dev/null @@ -1,327 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; -import 'package:madari_client/engine/engine.dart'; -import 'package:pdfrx/pdfrx.dart'; -import 'package:pocketbase/pocketbase.dart'; -import 'package:url_launcher/url_launcher.dart'; - -class MagicShowMarkdown extends StatefulWidget { - final RecordModel record; - final List pages; - final PdfViewerController controller; - final String fileName; - - const MagicShowMarkdown({ - super.key, - required this.record, - required this.pages, - required this.controller, - required this.fileName, - }); - - @override - State createState() => _MagicShowMarkdownState(); -} - -class _MagicShowMarkdownState extends State { - final List markdownChunks = []; - bool isLoading = true; - String? error; - bool isStreaming = false; - final Set selectedChunks = {}; - bool isSelectionMode = false; - final RegExp mermaidRegex = RegExp(r'```mermaid\n([\s\S]*?)```'); - - @override - void dispose() { - super.dispose(); - } - - @override - void initState() { - super.initState(); - _extractAndStream(); - } - - Future _extractPdfText() async { - return ""; - } - - void _extractAndStream() async {} - - void _retryStreaming() { - setState(() { - error = null; - isLoading = true; - markdownChunks.clear(); - }); - _extractAndStream(); - } - - void _handleMarkdownTap(String text, String? href) { - if (href != null) { - launchUrl(Uri.parse(href)); - } - } - - void _copyText(String text) { - Clipboard.setData(ClipboardData(text: text)); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Copied to clipboard')), - ); - } - - void _deleteChunk(int index) { - setState(() { - markdownChunks.removeAt(index); - selectedChunks.remove(index); - }); - } - - void _saveSelectedChunks() async { - try { - final selectedTexts = selectedChunks.toList()..sort(); - - final textItem = - selectedTexts.map((index) => markdownChunks[index]).toList(); - - await AppEngine.engine.pb.collection('saved_responses').create( - body: { - 'content': textItem.join("\n\n"), - 'file_name': widget.fileName, - 'user': AppEngine.engine.pb.authStore.record!.id, - }, - ); - - if (context.mounted && mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Saved successfully')), - ); - } - setState(() { - isSelectionMode = false; - selectedChunks.clear(); - }); - } catch (e) { - if (context.mounted && mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'Error saving: ${(e as ClientException).response["message"]}', - ), - ), - ); - } - } - } - - Widget _buildMarkdownContent(String chunk) { - final mermaidMatches = mermaidRegex.allMatches(chunk); - - if (mermaidMatches.isNotEmpty) { - List contentWidgets = []; - int lastEnd = 0; - - // Process each match and the text between matches - for (var match in mermaidMatches) { - // Add markdown content before the diagram if exists - if (match.start > lastEnd) { - final beforeText = chunk.substring(lastEnd, match.start); - if (beforeText.trim().isNotEmpty) { - contentWidgets.add( - MarkdownBody( - data: beforeText, - selectable: true, - onTapLink: (text, href, title) => - _handleMarkdownTap(text, href), - ), - ); - } - } - - lastEnd = match.end; - } - - // Add any remaining markdown content after the last diagram - if (lastEnd < chunk.length) { - final afterText = chunk.substring(lastEnd); - if (afterText.trim().isNotEmpty) { - contentWidgets.add( - MarkdownBody( - data: afterText, - selectable: true, - onTapLink: (text, href, title) => _handleMarkdownTap(text, href), - ), - ); - } - } - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: contentWidgets, - ); - } - - return MarkdownBody( - data: chunk, - selectable: true, - onTapLink: (text, href, title) => _handleMarkdownTap(text, href), - ); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text("Output"), - actions: [ - if (isSelectionMode) ...[ - TextButton.icon( - onPressed: selectedChunks.isEmpty ? null : _saveSelectedChunks, - icon: const Icon(Icons.save), - label: Text('Save ${selectedChunks.length}'), - ), - IconButton( - onPressed: () => setState(() { - isSelectionMode = false; - selectedChunks.clear(); - }), - icon: const Icon(Icons.close), - ), - ] else - IconButton( - onPressed: () => setState(() => isSelectionMode = true), - icon: const Icon(Icons.checklist), - ), - ], - ), - body: Center( - child: Container( - constraints: const BoxConstraints( - maxWidth: 800, - ), - child: ListView.builder( - itemCount: - markdownChunks.length + (isStreaming || error != null ? 1 : 0), - itemBuilder: (context, index) { - if (index == markdownChunks.length) { - if (error != null) { - Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.error_outline, - size: 48, - color: Theme.of(context).colorScheme.error, - ), - const SizedBox(height: 16), - Text( - 'Error loading content', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 8), - Text( - error!, - style: Theme.of(context).textTheme.bodyMedium, - textAlign: TextAlign.center, - ), - const SizedBox(height: 16), - FilledButton.icon( - onPressed: _retryStreaming, - icon: const Icon(Icons.refresh), - label: const Text('Retry'), - ), - ], - ), - ); - } - - return _buildStreamingIndicator(); - } - - final chunk = - markdownChunks[index].replaceAll("---", "\n").trim(); - final isSelected = selectedChunks.contains(index); - - return Card( - margin: const EdgeInsets.all(8), - child: InkWell( - onTap: isSelectionMode - ? () => setState(() { - if (isSelected) { - selectedChunks.remove(index); - } else { - selectedChunks.add(index); - } - }) - : null, - child: Container( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - if (isSelectionMode) - Checkbox( - value: isSelected, - onChanged: (value) => setState(() { - if (value ?? false) { - selectedChunks.add(index); - } else { - selectedChunks.remove(index); - } - }), - ), - Expanded( - child: _buildMarkdownContent(chunk), - ), - ], - ), - const SizedBox(height: 8), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - IconButton( - onPressed: () => _copyText(chunk), - icon: const Icon(Icons.copy), - tooltip: 'Copy', - ), - IconButton( - onPressed: () => _deleteChunk(index), - icon: const Icon(Icons.delete_outline), - tooltip: 'Delete', - ), - ], - ), - ], - ), - ), - ), - ); - }, - ), - ), - ), - ); - } - - Widget _buildStreamingIndicator() { - return Container( - padding: const EdgeInsets.all(16), - alignment: Alignment.center, - child: Column( - children: [ - const CircularProgressIndicator(), - const SizedBox(height: 16), - Text( - 'Loading content...', - style: Theme.of(context).textTheme.bodyMedium, - ), - ], - ), - ); - } -} diff --git a/lib/features/doc_viewer/container/pdf/markers_view.dart b/lib/features/doc_viewer/container/pdf/markers_view.dart deleted file mode 100644 index eb7c7db..0000000 --- a/lib/features/doc_viewer/container/pdf/markers_view.dart +++ /dev/null @@ -1,63 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:pdfrx/pdfrx.dart'; - -class Marker { - final Color color; - final PdfTextRanges ranges; - - Marker(this.color, this.ranges); -} - -class MarkersView extends StatefulWidget { - const MarkersView({ - super.key, - required this.markers, - this.onTap, - this.onDeleteTap, - }); - - final List markers; - final void Function(Marker ranges)? onTap; - final void Function(Marker ranges)? onDeleteTap; - - @override - State createState() => _MarkersViewState(); -} - -class _MarkersViewState extends State { - @override - Widget build(BuildContext context) { - return ListView.builder( - itemBuilder: (context, index) { - final marker = widget.markers[index]; - return Padding( - padding: const EdgeInsets.only(bottom: 1), - child: Stack( - children: [ - Material( - color: marker.color.withAlpha(100), - child: InkWell( - onTap: () => widget.onTap?.call(marker), - child: SizedBox( - width: double.infinity, - height: 40, - child: Text( - 'Page #${marker.ranges.pageNumber} - ${marker.ranges.text}'), - ), - ), - ), - Align( - alignment: Alignment.centerRight, - child: IconButton( - icon: const Icon(Icons.delete), - onPressed: () => widget.onDeleteTap?.call(marker), - ), - ), - ], - ), - ); - }, - itemCount: widget.markers.length, - ); - } -} diff --git a/lib/features/doc_viewer/container/pdf/outline_view.dart b/lib/features/doc_viewer/container/pdf/outline_view.dart deleted file mode 100644 index 45762b8..0000000 --- a/lib/features/doc_viewer/container/pdf/outline_view.dart +++ /dev/null @@ -1,54 +0,0 @@ -// -// Just a rough implementation of the document index -// -import 'package:flutter/material.dart'; -import 'package:pdfrx/pdfrx.dart'; - -class OutlineView extends StatelessWidget { - const OutlineView({ - super.key, - required this.outline, - required this.controller, - }); - - final List? outline; - final PdfViewerController controller; - - @override - Widget build(BuildContext context) { - final list = _getOutlineList(outline, 0).toList(); - return SizedBox( - width: list.isEmpty ? 0 : 200, - child: ListView.builder( - itemCount: list.length, - itemBuilder: (context, index) { - final item = list[index]; - return InkWell( - onTap: () => controller.goToDest(item.node.dest), - child: Container( - margin: EdgeInsets.only( - left: item.level * 16.0 + 8, - top: 8, - bottom: 8, - ), - child: Text( - item.node.title, - softWrap: false, - ), - ), - ); - }, - ), - ); - } - - /// Recursively create outline indent structure - Iterable<({PdfOutlineNode node, int level})> _getOutlineList( - List? outline, int level) sync* { - if (outline == null) return; - for (var node in outline) { - yield (node: node, level: level); - yield* _getOutlineList(node.children, level + 1); - } - } -} diff --git a/lib/features/doc_viewer/container/pdf/password_dialog.dart b/lib/features/doc_viewer/container/pdf/password_dialog.dart deleted file mode 100644 index 611a79e..0000000 --- a/lib/features/doc_viewer/container/pdf/password_dialog.dart +++ /dev/null @@ -1,34 +0,0 @@ -// -// Simple password dialog -// -import 'package:flutter/material.dart'; - -Future passwordDialog(BuildContext context) async { - final textController = TextEditingController(); - return await showDialog( - context: context, - barrierDismissible: false, - builder: (context) { - return AlertDialog( - title: const Text('Enter password'), - content: TextField( - controller: textController, - autofocus: true, - keyboardType: TextInputType.visiblePassword, - obscureText: true, - onSubmitted: (value) => Navigator.of(context).pop(value), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(null), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.of(context).pop(textController.text), - child: const Text('OK'), - ), - ], - ); - }, - ); -} diff --git a/lib/features/doc_viewer/container/pdf/search_view.dart b/lib/features/doc_viewer/container/pdf/search_view.dart deleted file mode 100644 index 0eee9da..0000000 --- a/lib/features/doc_viewer/container/pdf/search_view.dart +++ /dev/null @@ -1,376 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:pdfrx/pdfrx.dart'; -import 'package:synchronized/extension.dart'; - -class TextSearchView extends StatefulWidget { - const TextSearchView({ - super.key, - required this.textSearcher, - }); - - final PdfTextSearcher textSearcher; - - @override - State createState() => _TextSearchViewState(); -} - -class _TextSearchViewState extends State { - final focusNode = FocusNode(); - final searchTextController = TextEditingController(); - late final pageTextStore = - PdfPageTextCache(textSearcher: widget.textSearcher); - final scrollController = ScrollController(); - - @override - void initState() { - widget.textSearcher.addListener(_searchResultUpdated); - searchTextController.addListener(_searchTextUpdated); - super.initState(); - } - - @override - void dispose() { - scrollController.dispose(); - widget.textSearcher.removeListener(_searchResultUpdated); - searchTextController.removeListener(_searchTextUpdated); - searchTextController.dispose(); - focusNode.dispose(); - super.dispose(); - } - - void _searchTextUpdated() { - widget.textSearcher.startTextSearch(searchTextController.text); - } - - int? _currentSearchSession; - final _matchIndexToListIndex = []; - final _listIndexToMatchIndex = []; - - void _searchResultUpdated() { - if (_currentSearchSession != widget.textSearcher.searchSession) { - _currentSearchSession = widget.textSearcher.searchSession; - _matchIndexToListIndex.clear(); - _listIndexToMatchIndex.clear(); - } - for (int i = _matchIndexToListIndex.length; - i < widget.textSearcher.matches.length; - i++) { - if (i == 0 || - widget.textSearcher.matches[i - 1].pageNumber != - widget.textSearcher.matches[i].pageNumber) { - _listIndexToMatchIndex.add(-widget.textSearcher.matches[i] - .pageNumber); // negative index to indicate page header - } - _matchIndexToListIndex.add(_listIndexToMatchIndex.length); - _listIndexToMatchIndex.add(i); - } - - if (mounted) setState(() {}); - } - - static const double itemHeight = 50; - - @override - Widget build(BuildContext context) { - return Column( - children: [ - widget.textSearcher.isSearching - ? LinearProgressIndicator( - value: widget.textSearcher.searchProgress, - minHeight: 4, - ) - : const SizedBox(height: 4), - Row( - children: [ - const SizedBox(width: 8), - Expanded( - child: Stack( - alignment: Alignment.centerLeft, - children: [ - TextField( - autofocus: true, - focusNode: focusNode, - controller: searchTextController, - decoration: const InputDecoration( - contentPadding: EdgeInsets.only(right: 50), - ), - textInputAction: TextInputAction.search, - onSubmitted: (value) { - focusNode.requestFocus(); - }, - ), - if (widget.textSearcher.hasMatches) - Align( - alignment: Alignment.centerRight, - child: Text( - '${widget.textSearcher.currentIndex! + 1} / ${widget.textSearcher.matches.length}', - style: const TextStyle( - fontSize: 12, - color: Colors.grey, - ), - ), - ), - ], - ), - ), - const SizedBox(width: 4), - IconButton( - onPressed: (widget.textSearcher.currentIndex ?? 0) < - widget.textSearcher.matches.length - ? () async { - await widget.textSearcher.goToNextMatch(); - _conditionScrollPosition(); - } - : null, - icon: const Icon(Icons.arrow_downward), - iconSize: 20, - ), - IconButton( - onPressed: (widget.textSearcher.currentIndex ?? 0) > 0 - ? () async { - await widget.textSearcher.goToPrevMatch(); - _conditionScrollPosition(); - } - : null, - icon: const Icon(Icons.arrow_upward), - iconSize: 20, - ), - ], - ), - const SizedBox(height: 4), - Expanded( - child: ListView.builder( - key: Key(searchTextController.text), - controller: scrollController, - itemCount: _listIndexToMatchIndex.length, - itemBuilder: (context, index) { - final matchIndex = _listIndexToMatchIndex[index]; - if (matchIndex >= 0 && - matchIndex < widget.textSearcher.matches.length) { - final match = widget.textSearcher.matches[matchIndex]; - return SearchResultTile( - key: ValueKey(index), - match: match, - onTap: () async { - await widget.textSearcher.goToMatchOfIndex(matchIndex); - if (mounted) setState(() {}); - }, - pageTextStore: pageTextStore, - height: itemHeight, - isCurrent: matchIndex == widget.textSearcher.currentIndex, - ); - } else { - return Container( - height: itemHeight, - alignment: Alignment.bottomLeft, - padding: const EdgeInsets.only(bottom: 10), - child: Text( - 'Page ${-matchIndex}', - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - ); - } - }, - ), - ), - ], - ); - } - - void _conditionScrollPosition() { - final pos = scrollController.position; - final newPos = - itemHeight * _matchIndexToListIndex[widget.textSearcher.currentIndex!]; - if (newPos + itemHeight > pos.pixels + pos.viewportDimension) { - scrollController.animateTo( - newPos + itemHeight - pos.viewportDimension, - duration: const Duration(milliseconds: 300), - curve: Curves.decelerate, - ); - } else if (newPos < pos.pixels) { - scrollController.animateTo( - newPos, - duration: const Duration(milliseconds: 300), - curve: Curves.decelerate, - ); - } - - if (mounted) setState(() {}); - } -} - -class SearchResultTile extends StatefulWidget { - const SearchResultTile({ - super.key, - required this.match, - required this.onTap, - required this.pageTextStore, - required this.height, - required this.isCurrent, - }); - - final PdfTextRangeWithFragments match; - final void Function() onTap; - final PdfPageTextCache pageTextStore; - final double height; - final bool isCurrent; - - @override - State createState() => _SearchResultTileState(); -} - -class _SearchResultTileState extends State { - PdfPageText? pageText; - - @override - void initState() { - super.initState(); - _load(); - } - - void _release() { - if (pageText != null) { - widget.pageTextStore.releaseText(pageText!.pageNumber); - } - } - - Future _load() async { - _release(); - pageText = await widget.pageTextStore.loadText(widget.match.pageNumber); - if (mounted) { - setState(() {}); - } - } - - @override - void dispose() { - _release(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final text = Text.rich(createTextSpanForMatch(pageText, widget.match)); - - return SizedBox( - height: widget.height, - child: Material( - color: widget.isCurrent - ? DefaultSelectionStyle.of(context).selectionColor! - : null, - child: InkWell( - onTap: () => widget.onTap(), - child: Container( - decoration: const BoxDecoration( - border: Border( - bottom: BorderSide( - color: Colors.black12, - width: 0.5, - ), - ), - ), - padding: const EdgeInsets.all(3), - child: text, - ), - ), - ), - ); - } - - TextSpan createTextSpanForMatch( - PdfPageText? pageText, PdfTextRangeWithFragments match, - {TextStyle? style}) { - style ??= const TextStyle( - fontSize: 14, - ); - if (pageText == null) { - return TextSpan( - text: match.fragments.map((f) => f.text).join(), - style: style, - ); - } - final fullText = pageText.fullText; - int first = 0; - for (int i = match.fragments.first.index - 1; i >= 0;) { - if (fullText[i] == '\n') { - first = i + 1; - break; - } - i--; - } - int last = fullText.length; - for (int i = match.fragments.last.end; i < fullText.length; i++) { - if (fullText[i] == '\n') { - last = i; - break; - } - } - - final header = - fullText.substring(first, match.fragments.first.index + match.start); - final body = fullText.substring(match.fragments.first.index + match.start, - match.fragments.last.index + match.end); - final footer = - fullText.substring(match.fragments.last.index + match.end, last); - - return TextSpan( - children: [ - TextSpan(text: header), - TextSpan( - text: body, - style: const TextStyle( - backgroundColor: Colors.yellow, - ), - ), - TextSpan(text: footer), - ], - style: style, - ); - } -} - -/// A helper class to cache loaded page texts. -class PdfPageTextCache { - final PdfTextSearcher textSearcher; - PdfPageTextCache({ - required this.textSearcher, - }); - - final _pageTextRefs = {}; - - /// load the text of the given page number. - Future loadText(int pageNumber) async { - final ref = _pageTextRefs[pageNumber]; - if (ref != null) { - ref.refCount++; - return ref.pageText; - } - return await synchronized(() async { - var ref = _pageTextRefs[pageNumber]; - if (ref == null) { - final pageText = await textSearcher.loadText(pageNumber: pageNumber); - ref = _pageTextRefs[pageNumber] = _PdfPageTextRefCount(pageText!); - } - ref.refCount++; - return ref.pageText; - }); - } - - /// Release the text of the given page number. - void releaseText(int pageNumber) { - final ref = _pageTextRefs[pageNumber]!; - ref.refCount--; - if (ref.refCount == 0) { - _pageTextRefs.remove(pageNumber); - } - } -} - -class _PdfPageTextRefCount { - _PdfPageTextRefCount(this.pageText); - final PdfPageText pageText; - int refCount = 0; -} diff --git a/lib/features/doc_viewer/container/pdf/thumbnails_view.dart b/lib/features/doc_viewer/container/pdf/thumbnails_view.dart deleted file mode 100644 index c223947..0000000 --- a/lib/features/doc_viewer/container/pdf/thumbnails_view.dart +++ /dev/null @@ -1,55 +0,0 @@ -// -// Super simple thumbnails view -// -import 'package:flutter/material.dart'; -import 'package:pdfrx/pdfrx.dart'; - -class ThumbnailsView extends StatelessWidget { - const ThumbnailsView( - {super.key, required this.documentRef, required this.controller}); - - final PdfDocumentRef? documentRef; - final PdfViewerController? controller; - - @override - Widget build(BuildContext context) { - return Container( - color: Colors.grey, - child: documentRef == null - ? null - : PdfDocumentViewBuilder( - documentRef: documentRef!, - builder: (context, document) => ListView.builder( - itemCount: document?.pages.length ?? 0, - itemBuilder: (context, index) { - return Container( - margin: const EdgeInsets.all(8), - height: 240, - child: Column( - children: [ - SizedBox( - height: 220, - child: InkWell( - onTap: () => controller!.goToPage( - pageNumber: index + 1, - anchor: PdfPageAnchor.top, - ), - child: PdfPageView( - document: document, - pageNumber: index + 1, - alignment: Alignment.center, - ), - ), - ), - Text( - '${index + 1}', - ), - ], - ), - ); - }, - ), - ), - ); - } -} diff --git a/lib/features/doc_viewer/container/pdf_viewer.dart b/lib/features/doc_viewer/container/pdf_viewer.dart deleted file mode 100644 index 9065873..0000000 --- a/lib/features/doc_viewer/container/pdf_viewer.dart +++ /dev/null @@ -1,389 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:madari_client/features/doc_viewer/container/pdf/magic_show_markdown.dart'; -import 'package:madari_client/features/doc_viewer/types/doc_source.dart'; -import 'package:pdfrx/pdfrx.dart'; -import 'package:pocketbase/pocketbase.dart'; -import 'package:url_launcher/url_launcher.dart'; - -import 'pdf/magic_bottom_sheet.dart'; -import 'pdf/markers_view.dart'; -import 'pdf/outline_view.dart'; -import 'pdf/password_dialog.dart'; -import 'pdf/search_view.dart'; - -class PDFViewerContainer extends StatefulWidget { - final DocSource source; - - const PDFViewerContainer({ - super.key, - required this.source, - }); - - @override - State createState() => _PDFViewerContainerState(); -} - -class _PDFViewerContainerState extends State { - final documentRef = ValueNotifier(null); - final controller = PdfViewerController(); - final showLeftPane = ValueNotifier(false); - final outline = ValueNotifier?>(null); - late final textSearcher = PdfTextSearcher(controller)..addListener(_update); - final _markers = >{}; - List? _textSelections; - - void _update() { - if (mounted) { - setState(() {}); - } - } - - @override - void dispose() { - textSearcher.removeListener(_update); - textSearcher.dispose(); - showLeftPane.dispose(); - outline.dispose(); - documentRef.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - backgroundColor: Colors.black, - title: Text( - widget.source.title, - style: Theme.of(context).textTheme.bodyLarge, - ), - actions: [ - IconButton( - icon: const Icon(Icons.list), - onPressed: () { - showLeftPane.value = !showLeftPane.value; - }, - ), - IconButton( - onPressed: () { - showModalBottomSheet( - context: context, - builder: (context) { - return Padding( - padding: const EdgeInsets.all(12), - child: search(), - ); - }, - ); - }, - icon: const Icon( - Icons.search, - ), - ) - ], - ), - floatingActionButton: Row( - children: [ - const Spacer(), - IconButton.filledTonal( - icon: const Icon(Icons.zoom_in), - onPressed: () => controller.zoomUp(), - ), - IconButton.filledTonal( - icon: const Icon(Icons.zoom_out), - onPressed: () => controller.zoomDown(), - ), - const SizedBox( - width: 8, - ), - FloatingActionButton.extended( - label: const Text("Magic"), - onPressed: () async { - final result = await showModalBottomSheet( - context: context, - builder: (ctx) { - return MagicBottomSheet( - controller: controller, - ); - }, - ); - - if (result == null || - !context.mounted || - (result is List) && result.length != 2) { - return; - } - - if (result[1] == null || result[0] == null) { - return; - } - - Navigator.of(context).push( - MaterialPageRoute( - builder: (ctx) { - return MagicShowMarkdown( - record: result[0] as RecordModel, - pages: result[1] as List, - controller: controller, - fileName: widget.source.title, - ); - }, - ), - ); - }, - icon: const Icon( - Icons.auto_awesome, - ), - ) - ], - ), - body: Row( - children: [ - AnimatedSize( - duration: const Duration(milliseconds: 300), - child: ValueListenableBuilder( - valueListenable: showLeftPane, - builder: (context, showLeftPane, child) => SizedBox( - width: showLeftPane ? 300 : 0, - child: child!, - ), - child: Padding( - padding: const EdgeInsets.fromLTRB(1, 0, 4, 0), - child: ValueListenableBuilder( - valueListenable: outline, - builder: (context, outline, child) => OutlineView( - outline: outline, - controller: controller, - ), - ), - ), - ), - ), - Expanded( - child: Stack( - children: [ - if (widget.source is FileSource) - PdfViewer.file( - (widget.source as FileSource).filePath, - passwordProvider: () => passwordDialog(context), - controller: controller, - params: params, - ), - if (widget.source is URLSource) - PdfViewer.uri( - Uri.parse((widget.source as URLSource).url), - passwordProvider: () => passwordDialog(context), - headers: (widget.source as URLSource).headers, - controller: controller, - params: params, - ), - ], - ), - ), - ], - ), - ); - } - - Widget search() { - return ValueListenableBuilder( - valueListenable: documentRef, - builder: (context, documentRef, child) => TextSearchView( - textSearcher: textSearcher, - ), - ); - } - - PdfViewerParams get params { - return PdfViewerParams( - enableTextSelection: true, - maxScale: 8, - onViewSizeChanged: (viewSize, oldViewSize, controller) { - if (oldViewSize != null) { - final centerPosition = controller.value.calcPosition(oldViewSize); - final newMatrix = controller.calcMatrixFor(centerPosition); - Future.delayed( - const Duration(milliseconds: 200), - () => controller.goTo(newMatrix), - ); - } - }, - viewerOverlayBuilder: (context, size, handleLinkTap) => [ - GestureDetector( - behavior: HitTestBehavior.translucent, - onTapUp: (details) { - handleLinkTap(details.localPosition); - }, - onDoubleTap: () { - if (controller.currentZoom <= 1) { - controller.zoomUp(loop: true); - } else { - controller.zoomDown( - loop: false, - ); - } - }, - child: IgnorePointer( - child: SizedBox(width: size.width, height: size.height), - ), - ), - PdfViewerScrollThumb( - controller: controller, - orientation: ScrollbarOrientation.right, - thumbSize: const Size(44, 28), - thumbBuilder: (context, thumbSize, pageNumber, controller) => - ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Container( - color: Colors.black, - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - const SizedBox( - width: 4, - ), - const Icon( - Icons.drag_indicator, - size: 14, - ), - Center( - child: Text( - pageNumber.toString(), - style: const TextStyle(color: Colors.white), - ), - ), - const SizedBox( - width: 8, - ), - ], - ), - ), - ), - ), - PdfViewerScrollThumb( - controller: controller, - orientation: ScrollbarOrientation.bottom, - thumbSize: const Size(80, 22), - thumbBuilder: (context, thumbSize, pageNumber, controller) => - ClipRRect( - borderRadius: BorderRadius.circular(22), - child: Container( - color: Colors.black, - child: const Center( - child: Icon( - Icons.drag_indicator_outlined, - size: 18, - ), - ), - ), - ), - ), - ], - loadingBannerBuilder: (context, bytesDownloaded, totalBytes) => Center( - child: CircularProgressIndicator( - value: totalBytes != null ? bytesDownloaded / totalBytes : null, - backgroundColor: Colors.grey, - ), - ), - linkHandlerParams: PdfLinkHandlerParams( - onLinkTap: (link) { - if (link.url != null) { - navigateToUrl(link.url!); - } else if (link.dest != null) { - controller.goToDest(link.dest); - } - }, - ), - pagePaintCallbacks: [ - textSearcher.pageTextMatchPaintCallback, - _paintMarkers, - ], - onDocumentChanged: (document) async { - if (document == null) { - documentRef.value = null; - outline.value = null; - _textSelections = null; - _markers.clear(); - } - }, - onViewerReady: (document, controller) async { - documentRef.value = controller.documentRef; - outline.value = await document.loadOutline(); - }, - onTextSelectionChange: (selections) { - _textSelections = selections; - }, - ); - } - - void _paintMarkers(Canvas canvas, Rect pageRect, PdfPage page) { - final markers = _markers[page.pageNumber]; - if (markers == null) { - return; - } - for (final marker in markers) { - final paint = Paint() - ..color = marker.color.withAlpha(100) - ..style = PaintingStyle.fill; - - for (final range in marker.ranges.ranges) { - final f = PdfTextRangeWithFragments.fromTextRange( - marker.ranges.pageText, - range.start, - range.end, - ); - if (f != null) { - canvas.drawRect( - f.bounds.toRectInPageRect(page: page, pageRect: pageRect), - paint, - ); - } - } - } - } - - Future navigateToUrl(Uri url) async { - if (await shouldOpenUrl(context, url)) { - await launchUrl(url); - } - } - - Future shouldOpenUrl(BuildContext context, Uri url) async { - final result = await showDialog( - context: context, - barrierDismissible: false, - builder: (context) { - return AlertDialog( - title: const Text('Navigate to URL?'), - content: SelectionArea( - child: Text.rich( - TextSpan( - children: [ - const TextSpan( - text: - 'Do you want to navigate to the following location?\n'), - TextSpan( - text: url.toString(), - style: const TextStyle(color: Colors.blue), - ), - ], - ), - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.of(context).pop(true), - child: const Text('Go'), - ), - ], - ); - }, - ); - return result ?? false; - } -} diff --git a/lib/features/doc_viewer/container/photo_viewer.dart b/lib/features/doc_viewer/container/photo_viewer.dart deleted file mode 100644 index 94956dd..0000000 --- a/lib/features/doc_viewer/container/photo_viewer.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:madari_client/features/doc_viewer/types/doc_source.dart'; -import 'package:photo_view/photo_view.dart'; - -class PhotoViewer extends StatelessWidget { - final DocSource source; - const PhotoViewer({ - super.key, - required this.source, - }); - - @override - Widget build(BuildContext context) { - ImageProvider provider; - - if (source is FileSource) { - provider = FileImage(File((source as FileSource).filePath)); - } else if (source is URLSource) { - provider = NetworkImage((source as URLSource).url); - } else { - throw TypeError(); - } - - return Scaffold( - appBar: AppBar( - title: Text(source.title), - ), - body: PhotoView( - imageProvider: provider, - ), - ); - } -} diff --git a/lib/features/doc_viewer/container/video_viewer.dart b/lib/features/doc_viewer/container/video_viewer.dart deleted file mode 100644 index 7854c64..0000000 --- a/lib/features/doc_viewer/container/video_viewer.dart +++ /dev/null @@ -1,422 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:logging/logging.dart'; -import 'package:madari_client/features/connections/service/base_connection_service.dart'; -import 'package:madari_client/features/watch_history/service/base_watch_history.dart'; -import 'package:media_kit/media_kit.dart'; -import 'package:media_kit_video/media_kit_video.dart'; - -import '../../../utils/load_language.dart'; -import '../../connections/types/stremio/stremio_base.types.dart' as types; -import '../../trakt/service/trakt.service.dart'; -import '../../watch_history/service/zeee_watch_history.dart'; -import '../types/doc_source.dart'; -import 'video_viewer/video_viewer_ui.dart'; - -class VideoViewer extends StatefulWidget { - final DocSource source; - final LibraryItem? meta; - final BaseConnectionService? service; - final String? currentSeason; - final String? library; - - const VideoViewer({ - super.key, - required this.source, - this.meta, - this.service, - this.currentSeason, - this.library, - }); - - @override - State createState() => _VideoViewerState(); -} - -class _VideoViewerState extends State { - late LibraryItem? meta = widget.meta; - - final zeeeWatchHistory = ZeeeWatchHistoryStatic.service; - Timer? _timer; - late final Player player = Player( - configuration: const PlayerConfiguration( - title: "Madari", - ), - ); - final Logger _logger = Logger('VideoPlayer'); - - double get currentProgressInPercentage { - final duration = player.state.duration.inSeconds; - final position = player.state.position.inSeconds; - return duration > 0 ? (position / duration * 100) : 0; - } - - bool timeLoaded = false; - - Future? traktProgress; - - Future saveWatchHistory() async { - _logger.info('Starting to save watch history...'); - - final duration = player.state.duration.inSeconds; - - if (duration <= 30) { - _logger.info('Video is too short to track.'); - return; - } - - if (gotFromTraktDuration == false) { - _logger.info( - "Did not start the scrobbling because initially time is not retrieved from the API.", - ); - return; - } - - final position = player.state.position.inSeconds; - final progress = duration > 0 ? (position / duration * 100) : 0; - - if (progress < 0.01) { - _logger.info('No progress to save.'); - return; - } - - if (meta is types.Meta && TraktService.instance != null) { - try { - if (player.state.playing) { - _logger.info('Starting scrobbling...'); - await TraktService.instance!.startScrobbling( - meta: meta as types.Meta, - progress: currentProgressInPercentage, - ); - } else { - _logger.info('Stopping scrobbling...'); - await TraktService.instance!.stopScrobbling( - meta: meta as types.Meta, - progress: currentProgressInPercentage, - ); - } - } catch (e) { - _logger.severe('Error during scrobbling: $e'); - TraktService.instance!.debugLogs.add(e.toString()); - } - } else { - _logger.warning('Meta is not valid or TraktService is not initialized.'); - } - - await zeeeWatchHistory!.saveWatchHistory( - history: WatchHistory( - id: _source.id, - progress: progress.round(), - duration: duration.toDouble(), - episode: _source.episode, - season: _source.season, - ), - ); - - _logger.info('Watch history saved successfully.'); - } - - late final controller = VideoController( - player, - configuration: VideoControllerConfiguration( - enableHardwareAcceleration: !config.softwareAcceleration, - ), - ); - - late DocSource _source; - - bool gotFromTraktDuration = false; - - int? traktId; - - Future setDurationFromTrakt({ - Future? traktProgress, - }) async { - _logger.info('Setting duration from Trakt...'); - - try { - if (player.state.duration.inSeconds < 2) { - _logger.info('Duration is too short to set from Trakt.'); - return; - } - - if (gotFromTraktDuration) { - _logger.info('Duration already set from Trakt.'); - return; - } - - gotFromTraktDuration = true; - - if (!TraktService.isEnabled() || - (traktProgress ?? this.traktProgress) == null) { - _logger.info( - 'Trakt service is not enabled or progress is null. Playing video.'); - player.play(); - return; - } - - final progress = await (traktProgress ?? this.traktProgress); - - if (this.meta is! types.Meta) { - _logger.info('Meta is not of type types.Meta.'); - return; - } - - final meta = (progress ?? this.meta) as types.Meta; - - final duration = Duration( - seconds: calculateSecondsFromProgress( - player.state.duration.inSeconds.toDouble(), - meta.currentVideo?.progress ?? meta.progress ?? 0, - ), - ); - - if (duration.inSeconds > 10) { - _logger.info('Seeking to duration: $duration'); - await player.seek(duration); - } - - await player.play(); - _logger.info('Video started playing.'); - } catch (e) { - _logger.severe('Error setting duration from Trakt: $e'); - await player.play(); - } - } - - List listener = []; - - PlaybackConfig config = getPlaybackConfig(); - - Future setupVideoThings() async { - _logger.info('Setting up video things...'); - - if (TraktService.isEnabled()) { - traktProgress = null; - traktProgress = TraktService.instance!.getProgress( - meta as types.Meta, - bypassCache: true, - ); - } - - _duration = player.stream.duration.listen((item) async { - if (meta is types.Meta) { - setDurationFromTrakt(traktProgress: traktProgress); - } - - if (item.inSeconds != 0) { - _logger.info('Duration updated: $item'); - await saveWatchHistory(); - } - }); - - _timer = Timer.periodic(const Duration(seconds: 30), (timer) { - _logger.info('Periodic save watch history triggered.'); - saveWatchHistory(); - }); - - _streamListen = player.stream.playing.listen((playing) { - _logger.info('Playing state changed: $playing'); - saveWatchHistory(); - }); - - _logger.info('Loading file...'); - - return loadFile(); - } - - destroyVideoThing() async { - _logger.info('Destroying video things...'); - - timeLoaded = false; - gotFromTraktDuration = false; - traktProgress = null; - - for (final item in listener) { - item.cancel(); - } - listener = []; - _timer?.cancel(); - _streamListen?.cancel(); - _duration?.cancel(); - - if (meta is types.Meta && player.state.duration.inSeconds > 30) { - _logger.info('Stopping scrobbling and clearing cache...'); - await TraktService.instance!.stopScrobbling( - meta: meta as types.Meta, - progress: currentProgressInPercentage, - shouldClearCache: true, - traktId: traktId, - ); - } - - _logger.info('Video things destroyed.'); - } - - GlobalKey videoKey = GlobalKey(); - - generateNewKey() { - _logger.info('Generating new key...'); - videoKey = GlobalKey(); - - setState(() {}); - } - - @override - void initState() { - super.initState(); - _logger.info('Initializing VideoViewer...'); - - _source = widget.source; - - SystemChrome.setEnabledSystemUIMode( - SystemUiMode.immersiveSticky, - overlays: [], - ); - - if (player.platform is NativePlayer && !kIsWeb) { - Future.microtask(() async { - _logger.info('Setting network timeout...'); - await (player.platform as dynamic).setProperty('network-timeout', '60'); - }); - } - - onVideoChange( - _source, - widget.meta!, - ); - - _logger.info('VideoViewer initialized.'); - } - - Future loadFile() async { - _logger.info('Loading file...'); - - Duration duration = const Duration(seconds: 0); - - if (meta is types.Meta && TraktService.isEnabled()) { - _logger.info("Playing video ${(meta as types.Meta).selectedVideoIndex}"); - } else { - final item = await zeeeWatchHistory!.getItemWatchHistory( - ids: [ - WatchHistoryGetRequest( - id: _source.id, - season: _source.season, - episode: _source.episode, - ), - ], - ); - - duration = Duration( - seconds: item.isEmpty - ? 0 - : calculateSecondsFromProgress( - item.first.duration, - item.first.progress.toDouble(), - ), - ); - } - - _logger.info('Loading file for source: ${_source.id}'); - - switch (_source.runtimeType) { - case const (FileSource): - if (kIsWeb) { - _logger.info('FileSource is not supported on web.'); - return; - } - player.open( - Media( - (_source as FileSource).filePath, - start: duration, - ), - play: false, - ); - case const (URLSource): - case const (MediaURLSource): - case const (TorrentSource): - player.open( - Media( - (_source as URLSource).url, - httpHeaders: (_source as URLSource).headers, - start: duration, - ), - play: false, - ); - } - - _logger.info('File loaded successfully.'); - } - - StreamSubscription? _streamListen; - StreamSubscription? _duration; - - @override - void dispose() { - _logger.info('Disposing VideoViewer...'); - - SystemChrome.setPreferredOrientations([ - DeviceOrientation.portraitUp, - DeviceOrientation.portraitDown, - DeviceOrientation.landscapeLeft, - DeviceOrientation.landscapeRight, - ]); - - SystemChrome.setEnabledSystemUIMode( - SystemUiMode.edgeToEdge, - overlays: [], - ); - - destroyVideoThing(); - player.dispose(); - - super.dispose(); - - _logger.info('VideoViewer disposed.'); - } - - onVideoChange(DocSource source, LibraryItem item) async { - setState(() {}); - await destroyVideoThing(); - - _logger.info('Changing video source...'); - - _source = source; - meta = item; - setState(() {}); - await setupVideoThings(); - setState(() {}); - generateNewKey(); - - _logger.info('Video source changed successfully.'); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - body: VideoViewerUi( - key: videoKey, - controller: controller, - player: player, - config: config, - source: _source, - onLibrarySelect: () {}, - service: widget.service, - meta: meta, - onSourceChange: (source, meta) => onVideoChange(source, meta), - ), - ); - } -} - -int calculateSecondsFromProgress( - double duration, - double progressPercentage, -) { - final clampedProgress = progressPercentage.clamp(0.0, 100.0); - final currentSeconds = (duration * (clampedProgress / 100)).round(); - return currentSeconds; -} diff --git a/lib/features/doc_viewer/container/video_viewer/audio_track_selector.dart b/lib/features/doc_viewer/container/video_viewer/audio_track_selector.dart deleted file mode 100644 index 860a20d..0000000 --- a/lib/features/doc_viewer/container/video_viewer/audio_track_selector.dart +++ /dev/null @@ -1,85 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:madari_client/utils/load_language.dart'; -import 'package:media_kit/media_kit.dart'; - -class AudioTrackSelector extends StatefulWidget { - final Player player; - final PlaybackConfig config; - - const AudioTrackSelector({ - super.key, - required this.player, - required this.config, - }); - - @override - State createState() => _AudioTrackSelectorState(); -} - -class _AudioTrackSelectorState extends State { - List audioTracks = []; - Map languages = {}; - - @override - void initState() { - super.initState(); - - audioTracks = widget.player.state.tracks.audio.where((item) { - return item.id != "auto" && item.id != "no"; - }).toList(); - - loadLanguages(context).then((language) { - if (mounted) { - setState(() { - languages = language; - }); - } - }); - } - - @override - Widget build(BuildContext context) { - return Card( - child: Container( - height: MediaQuery.of(context).size.height * 0.4, - decoration: BoxDecoration( - color: Theme.of(context).dialogBackgroundColor, - borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), - ), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - 'Select Audio Track', - style: Theme.of(context).textTheme.titleMedium, - ), - ), - Expanded( - child: ListView.builder( - itemCount: audioTracks.length, - itemBuilder: (context, index) { - final currentItem = audioTracks[index]; - final title = currentItem.language ?? - currentItem.title ?? - currentItem.id; - return ListTile( - title: Text( - languages.containsKey(title) ? languages[title]! : title, - ), - selected: - widget.player.state.track.audio.id == currentItem.id, - onTap: () { - widget.player.setAudioTrack(currentItem); - Navigator.pop(context); - }, - ); - }, - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/features/doc_viewer/container/video_viewer/desktop_video_player.dart b/lib/features/doc_viewer/container/video_viewer/desktop_video_player.dart deleted file mode 100644 index e9f8bc9..0000000 --- a/lib/features/doc_viewer/container/video_viewer/desktop_video_player.dart +++ /dev/null @@ -1,209 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/cupertino.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:madari_client/features/connections/service/base_connection_service.dart'; -import 'package:madari_client/features/doc_viewer/container/video_viewer/season_source.dart'; -import 'package:madari_client/features/doc_viewer/container/video_viewer/torrent_stat.dart'; -import 'package:madari_client/features/doc_viewer/types/doc_source.dart'; -import 'package:media_kit/media_kit.dart'; -import 'package:media_kit_video/media_kit_video.dart'; -import 'package:window_manager/window_manager.dart'; - -import '../../../connections/types/stremio/stremio_base.types.dart'; - -MaterialDesktopVideoControlsThemeData getDesktopControls( - BuildContext context, { - required DocSource source, - required Player player, - Widget? library, - required Function() onSubtitleSelect, - required Function() onAudioSelect, - LibraryItem? meta, - required Function(int index) onVideoChange, -}) { - return MaterialDesktopVideoControlsThemeData( - toggleFullscreenOnDoublePress: true, - displaySeekBar: true, - topButtonBar: [ - SafeArea( - child: MaterialDesktopCustomButton( - onPressed: () { - Navigator.of(context).pop(); - }, - icon: const Icon(Icons.arrow_back), - ), - ), - SafeArea( - child: Center( - child: SizedBox( - width: MediaQuery.of(context).size.width - 120, - child: Text( - (meta is Meta && meta.currentVideo != null) - ? "${meta.name ?? ""} S${meta.currentVideo?.season} E${meta.currentVideo?.episode}" - : source.title.endsWith(".mp4") - ? source.title.substring(0, source.title.length - 4) - : source.title, - style: Theme.of(context).textTheme.bodyLarge, - ), - ), - ), - ), - const Spacer(), - if (meta is Meta) - if (meta.type == "series") - SeasonSource( - meta: meta, - isMobile: false, - player: player, - onVideoChange: onVideoChange, - ), - ], - bufferingIndicatorBuilder: source is TorrentSource - ? (ctx) { - return TorrentStats( - torrentHash: source.infoHash, - ); - } - : null, - playAndPauseOnTap: true, - bottomButtonBar: [ - const MaterialDesktopSkipPreviousButton(), - const MaterialDesktopPlayOrPauseButton(), - const MaterialDesktopSkipNextButton(), - const MaterialDesktopVolumeButton(), - const MaterialDesktopPositionIndicator(), - const Spacer(), - MaterialCustomButton( - onPressed: () { - final speeds = [ - 0.5, - 0.75, - 1.0, - 1.25, - 1.5, - 1.75, - 2.0, - 2.5, - 3.0, - 3.5, - 4.0, - 4.5, - 5.0 - ]; - showCupertinoModalPopup( - context: context, - builder: (ctx) => Card( - child: Container( - height: MediaQuery.of(context).size.height * 0.4, - decoration: BoxDecoration( - color: Theme.of(context).dialogBackgroundColor, - borderRadius: - const BorderRadius.vertical(top: Radius.circular(12)), - ), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - 'Select Playback Speed', - style: Theme.of(context).textTheme.titleMedium, - ), - ), - Expanded( - child: ListView.builder( - itemCount: speeds.length, - itemBuilder: (context, index) { - final speed = speeds[index]; - return ListTile( - title: Text('${speed}x'), - selected: player.state.rate == speed, - onTap: () { - player.setRate(speed); - Navigator.pop(context); - }, - ); - }, - ), - ), - ], - ), - ), - ), - ); - }, - icon: const Icon(Icons.speed), - ), - MaterialDesktopCustomButton( - onPressed: onSubtitleSelect, - icon: const Icon(Icons.subtitles), - ), - const SizedBox( - width: 12, - ), - MaterialDesktopCustomButton( - onPressed: onAudioSelect, - icon: const Icon(Icons.audiotrack), - ), - if (!kIsWeb && - (Platform.isLinux || Platform.isWindows || Platform.isMacOS)) - const AlwaysOnTopButton(), - const MaterialDesktopFullscreenButton(), - ], - ); -} - -class AlwaysOnTopButton extends StatefulWidget { - const AlwaysOnTopButton({super.key}); - - @override - State createState() => _AlwaysOnTopButtonState(); -} - -class _AlwaysOnTopButtonState extends State { - bool alwaysOnTop = false; - - @override - void initState() { - super.initState(); - - windowManager.isAlwaysOnTop().then((value) { - if (mounted) { - setState(() { - alwaysOnTop = value; - }); - } - }); - } - - @override - Widget build(BuildContext context) { - return Tooltip( - message: "Always on top", - child: MaterialDesktopCustomButton( - onPressed: () async { - if (await windowManager.isAlwaysOnTop()) { - windowManager.setAlwaysOnTop(false); - windowManager.setTitleBarStyle(TitleBarStyle.normal); - setState(() { - alwaysOnTop = false; - }); - windowManager.setVisibleOnAllWorkspaces(false); - } else { - windowManager.setAlwaysOnTop(true); - windowManager.setVisibleOnAllWorkspaces(true); - windowManager.setTitleBarStyle(TitleBarStyle.hidden); - setState(() { - alwaysOnTop = true; - }); - } - }, - icon: Icon( - alwaysOnTop ? Icons.push_pin : Icons.push_pin_outlined, - ), - iconSize: 22, - ), - ); - } -} diff --git a/lib/features/doc_viewer/container/video_viewer/season_source.dart b/lib/features/doc_viewer/container/video_viewer/season_source.dart deleted file mode 100644 index de0ad30..0000000 --- a/lib/features/doc_viewer/container/video_viewer/season_source.dart +++ /dev/null @@ -1,222 +0,0 @@ -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:media_kit/media_kit.dart'; -import 'package:media_kit_video/media_kit_video.dart'; - -import '../../../connections/types/stremio/stremio_base.types.dart'; - -class SeasonSource extends StatelessWidget { - final Meta meta; - final bool isMobile; - final Player player; - final Function(int index) onVideoChange; - - const SeasonSource({ - super.key, - required this.meta, - required this.isMobile, - required this.player, - required this.onVideoChange, - }); - - @override - Widget build(BuildContext context) { - return MaterialCustomButton( - onPressed: () => onSelectMobile(context), - icon: const Icon(Icons.list_alt), - ); - } - - onSelectDesktop(BuildContext context) { - showCupertinoDialog( - context: context, - builder: (context) { - return VideoSelectView( - meta: meta, - onVideoChange: onVideoChange, - ); - }, - ); - } - - onSelectMobile(BuildContext context) { - showCupertinoDialog( - context: context, - builder: (context) { - return VideoSelectView( - meta: meta, - onVideoChange: onVideoChange, - ); - }, - ); - } -} - -class VideoSelectView extends StatefulWidget { - final Meta meta; - final Function(int index) onVideoChange; - - const VideoSelectView({ - super.key, - required this.meta, - required this.onVideoChange, - }); - - @override - State createState() => _VideoSelectViewState(); -} - -class _VideoSelectViewState extends State { - final ScrollController controller = ScrollController(); - - @override - void initState() { - super.initState(); - - if (widget.meta.selectedVideoIndex != null) { - WidgetsBinding.instance.addPostFrameCallback((_) { - const itemWidth = 240.0 + 16.0; - final offset = widget.meta.selectedVideoIndex! * itemWidth; - - controller.jumpTo(offset); - }); - } - } - - @override - void dispose() { - super.dispose(); - controller.dispose(); - } - - @override - Widget build(BuildContext context) { - return GestureDetector( - onVerticalDragEnd: (details) { - if (details.primaryVelocity! > 0) { - Navigator.of(context).pop(); - } - }, - child: Scaffold( - backgroundColor: Colors.black38, - appBar: AppBar( - backgroundColor: Colors.transparent, - title: const Text("Episodes"), - ), - body: SafeArea( - child: Column( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - SizedBox( - height: 150, - child: ListView.builder( - controller: controller, - scrollDirection: Axis.horizontal, - itemBuilder: (context, index) { - final video = widget.meta.videos![index]; - - return Padding( - padding: const EdgeInsets.all(8.0), - child: InkWell( - onTap: () { - widget.onVideoChange(index); - }, - child: Stack( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Container( - decoration: BoxDecoration( - image: DecorationImage( - fit: BoxFit.fill, - image: CachedNetworkImageProvider( - video.thumbnail ?? - widget.meta.poster ?? - widget.meta.background ?? - ""), - ), - ), - child: SizedBox( - width: 240, - child: Column( - mainAxisAlignment: MainAxisAlignment.end, - crossAxisAlignment: - CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.max, - children: [ - Container( - decoration: const BoxDecoration( - gradient: LinearGradient( - colors: [ - Colors.black, - Colors.black54, - Colors.black38, - ], - ), - ), - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - "S${video.season} E${video.episode}", - style: Theme.of(context) - .textTheme - .bodyLarge, - ), - Text( - video.name ?? video.title ?? "", - style: Theme.of(context) - .textTheme - .bodyLarge, - ), - ], - ), - ), - ), - ], - ), - ), - ), - ), - if (widget.meta.selectedVideoIndex == index) - Positioned( - child: Container( - decoration: const BoxDecoration( - gradient: LinearGradient( - colors: [ - Colors.black, - Colors.black54, - Colors.black38, - ], - ), - ), - child: const Padding( - padding: EdgeInsets.all(8.0), - child: Row( - children: [ - Text("Playing"), - Icon(Icons.play_arrow), - ], - ), - ), - ), - ), - ], - ), - ), - ); - }, - itemCount: (widget.meta.videos ?? []).length, - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/features/doc_viewer/container/video_viewer/subtitle_selector.dart b/lib/features/doc_viewer/container/video_viewer/subtitle_selector.dart deleted file mode 100644 index 5f96c81..0000000 --- a/lib/features/doc_viewer/container/video_viewer/subtitle_selector.dart +++ /dev/null @@ -1,207 +0,0 @@ -import 'dart:async'; -import 'dart:math'; - -import 'package:flutter/material.dart'; -import 'package:madari_client/features/connections/service/stremio_connection_service.dart'; -import 'package:madari_client/utils/load_language.dart'; -import 'package:media_kit/media_kit.dart'; -import 'package:shimmer/shimmer.dart'; - -import '../../../connections/service/base_connection_service.dart'; -import '../../../connections/types/stremio/stremio_base.types.dart'; - -Map> externalSubtitlesCache = {}; - -class SubtitleSelector extends StatefulWidget { - final Player player; - final PlaybackConfig config; - final BaseConnectionService? service; - final LibraryItem? meta; - - const SubtitleSelector({ - super.key, - required this.player, - required this.config, - required this.service, - this.meta, - }); - - @override - State createState() => _SubtitleSelectorState(); -} - -class _SubtitleSelectorState extends State { - List subtitles = []; - Map languages = {}; - Stream>? externalSubtitles; - - late StreamSubscription> _subtitles; - - @override - void initState() { - super.initState(); - - if (widget.service is StremioConnectionService && widget.meta is Meta) { - final meta = widget.meta as Meta; - - if (externalSubtitlesCache.containsKey(meta.id)) { - externalSubtitles = Stream.value(externalSubtitlesCache[meta.id]!); - } else { - externalSubtitles = (widget.service as StremioConnectionService) - .getSubtitles(meta) - .map((item) { - externalSubtitlesCache[meta.id] = item; - - return item; - }); - } - } - - onPlaybackReady(widget.player.state.tracks); - _subtitles = widget.player.stream.subtitle.listen((item) { - onPlaybackReady(widget.player.state.tracks); - }); - - loadLanguages(context).then((language) { - if (mounted) { - setState(() { - languages = language; - }); - } - }); - } - - @override - void dispose() { - super.dispose(); - - _subtitles.cancel(); - } - - void onPlaybackReady(Tracks tracks) { - setState(() { - subtitles = tracks.subtitle.where((item) { - return item.id != "auto" && item.id != "no"; - }).toList(); - }); - } - - @override - Widget build(BuildContext context) { - return Container( - constraints: const BoxConstraints( - maxWidth: 520, - ), - child: Card( - child: Container( - height: max(MediaQuery.of(context).size.height * 0.4, 400), - decoration: BoxDecoration( - color: Theme.of(context).dialogBackgroundColor, - borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), - ), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - 'Select Subtitle', - style: Theme.of(context).textTheme.titleMedium, - ), - ), - Expanded( - child: StreamBuilder>( - stream: externalSubtitles, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return Shimmer.fromColors( - baseColor: Colors.black54, - highlightColor: Colors.black54, - child: ListView.builder( - itemCount: 5, - itemBuilder: (context, index) { - return ListTile( - title: Container( - height: 20, - color: Colors.white, - ), - ); - }, - ), - ); - } else if (snapshot.hasError) { - return Center(child: Text('Error: ${snapshot.error}')); - } else if (!snapshot.hasData || snapshot.data!.isEmpty) { - return ListView.builder( - itemCount: subtitles.length, - itemBuilder: (context, index) { - final currentItem = subtitles[index]; - final title = currentItem.language ?? - currentItem.title ?? - currentItem.id; - - return ListTile( - title: Text( - languages.containsKey(title) - ? languages[title]! - : title, - ), - selected: widget.player.state.track.subtitle.id == - currentItem.id, - onTap: () { - widget.player.setSubtitleTrack(currentItem); - Navigator.pop(context); - }, - ); - }, - ); - } else { - final externalSubtitlesList = snapshot.data!; - final allSubtitles = [ - SubtitleTrack.no(), - ...subtitles, - ...externalSubtitlesList.map( - (subtitle) { - return SubtitleTrack.uri( - subtitle.url, - language: subtitle.lang, - title: - "${languages[subtitle.lang] ?? subtitle.lang} ${subtitle.id}", - ); - }, - ), - ]; - - return ListView.builder( - itemCount: allSubtitles.length, - itemBuilder: (context, index) { - final currentItem = allSubtitles[index]; - final title = currentItem.language ?? - currentItem.title ?? - currentItem.id; - - final isExternal = currentItem.uri; - - return ListTile( - title: Text( - "${languages.containsKey(title) ? languages[title]! : title == "no" ? "No subtitle" : title} ${isExternal ? "(External) (${Uri.parse(currentItem.id).host})" : ""}", - ), - selected: widget.player.state.track.subtitle.id == - currentItem.id, - onTap: () async { - await widget.player.setSubtitleTrack(currentItem); - if (context.mounted) Navigator.pop(context); - }, - ); - }, - ); - } - }, - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/features/doc_viewer/container/video_viewer/torrent_stat.dart b/lib/features/doc_viewer/container/video_viewer/torrent_stat.dart deleted file mode 100644 index e76a4e7..0000000 --- a/lib/features/doc_viewer/container/video_viewer/torrent_stat.dart +++ /dev/null @@ -1,527 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:math'; - -import 'package:flutter/material.dart'; -import 'package:http/http.dart' as http; -import 'package:json_annotation/json_annotation.dart'; - -part 'torrent_stat.g.dart'; - -class TorrentStats extends StatefulWidget { - final String torrentHash; - - const TorrentStats({ - super.key, - required this.torrentHash, - }); - - @override - State createState() => _TorrentStatsState(); -} - -class _TorrentStatsState extends State { - late Timer _timer; - TorrentStat? stat; - bool hasOpenOnce = false; - - String _formatBytes(int bytes) { - if (bytes < 1024) return '$bytes B'; - if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(2)} KB'; - if (bytes < 1024 * 1024 * 1024) { - return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB'; - } - return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB'; - } - - @override - void initState() { - super.initState(); - _timer = Timer.periodic( - const Duration(seconds: 1), - (_) { - getStats(); - }, - ); - } - - getStats() async { - final result = await http.get( - Uri.parse( - "http://localhost:64544/torrents/${widget.torrentHash}/stats/v1"), - ); - - final data = TorrentStat.fromJson(jsonDecode(result.body)); - - if (mounted) { - setState(() { - stat = data; - hasOpenOnce = true; - }); - } - } - - @override - void dispose() { - super.dispose(); - - _timer.cancel(); - } - - @override - Widget build(BuildContext context) { - final media = MediaQuery.of(context); - final isSmallScreen = media.size.width < 600; - - if (stat == null) { - return Container( - width: min(media.size.width, 800), - height: min(media.size.height, 180), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - ), - child: const Center(child: CircularProgressIndicator()), - ); - } - - return Container( - width: min(media.size.width, 800), - decoration: BoxDecoration( - color: Colors.black54, - borderRadius: BorderRadius.circular(8), - ), - child: Padding( - padding: EdgeInsets.all(isSmallScreen ? 12.0 : 16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - // Status and Progress Row - if (stat?.live != null) ...[ - Row( - children: [ - // Status Indicator - Container( - width: 8, - height: 8, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: stat?.state == 'Downloading' - ? Colors.green - : Colors.grey, - ), - ), - const SizedBox(width: 8), - Text( - stat?.state ?? 'Loading...', - style: const TextStyle( - color: Colors.white70, - fontSize: 14, - ), - ), - const Spacer(), - Text( - '${((stat!.progressBytes / stat!.totalBytes) * 100).toStringAsFixed(1)}%', - style: const TextStyle( - color: Color(0xFFE50914), - fontSize: 14, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - const SizedBox(height: 8), - - // Progress Bar - LinearProgressIndicator( - value: stat!.progressBytes / stat!.totalBytes, - backgroundColor: Colors.grey[800], - valueColor: - const AlwaysStoppedAnimation(Color(0xFFE50914)), - ), - const SizedBox(height: 12), - - // Main Stats Grid - Wrap( - spacing: 16, - runSpacing: 12, - children: [ - _buildCompactStat( - Icons.download, - Colors.green, - stat!.live!.downloadSpeed.humanReadable, - ), - _buildCompactStat( - Icons.upload, - Colors.blue, - stat!.live!.uploadSpeed.humanReadable, - ), - if (stat!.live!.timeRemaining?.humanReadable != null) - _buildCompactStat( - Icons.timer, - Colors.orange, - stat!.live!.timeRemaining!.humanReadable, - ), - ], - ), - const SizedBox(height: 12), - - // Advanced Stats - Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(4), - ), - padding: const EdgeInsets.all(8), - child: Column( - children: [ - _buildAdvancedStatRow( - 'Peers', - '${stat!.live!.snapshot.peerStats.live}/${stat!.live!.snapshot.peerStats.seen}', - ), - if (stat!.live!.averagePieceDownloadTime?.secs != null) - _buildAdvancedStatRow( - 'Avg Download', - '${stat!.live!.averagePieceDownloadTime?.secs}s', - ), - _buildAdvancedStatRow( - 'Downloaded', - _formatBytes( - stat!.live!.snapshot.downloadedAndCheckedBytes), - ), - _buildAdvancedStatRow( - 'Uploaded', - _formatBytes(stat!.live!.snapshot.uploadedBytes), - ), - ], - ), - ), - ], - ], - ), - ), - ); - } - - Widget _buildCompactStat(IconData icon, Color color, String value) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, color: color, size: 16), - const SizedBox(width: 4), - Text( - value, - style: const TextStyle( - color: Colors.white, - fontSize: 13, - ), - ), - ], - ); - } - - Widget _buildAdvancedStatRow(String label, String value) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 2), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - label, - style: TextStyle( - color: Colors.grey[400], - fontSize: 12, - ), - ), - Text( - value, - style: const TextStyle( - color: Colors.white, - fontSize: 12, - ), - ), - ], - ), - ); - } -} - -@JsonSerializable() -class TorrentStat { - @JsonKey(name: "state") - final String state; - @JsonKey(name: "file_progress") - final List fileProgress; - @JsonKey(name: "error") - final dynamic error; - @JsonKey(name: "progress_bytes") - final int progressBytes; - @JsonKey(name: "uploaded_bytes") - final int uploadedBytes; - @JsonKey(name: "total_bytes") - final int totalBytes; - @JsonKey(name: "finished") - final bool finished; - @JsonKey(name: "live") - final Live? live; - - TorrentStat({ - required this.state, - required this.fileProgress, - required this.error, - required this.progressBytes, - required this.uploadedBytes, - required this.totalBytes, - required this.finished, - required this.live, - }); - - TorrentStat copyWith({ - String? state, - List? fileProgress, - dynamic error, - int? progressBytes, - int? uploadedBytes, - int? totalBytes, - bool? finished, - Live? live, - }) => - TorrentStat( - state: state ?? this.state, - fileProgress: fileProgress ?? this.fileProgress, - error: error ?? this.error, - progressBytes: progressBytes ?? this.progressBytes, - uploadedBytes: uploadedBytes ?? this.uploadedBytes, - totalBytes: totalBytes ?? this.totalBytes, - finished: finished ?? this.finished, - live: live ?? this.live, - ); - - factory TorrentStat.fromJson(Map json) => - _$TorrentStatFromJson(json); - - Map toJson() => _$TorrentStatToJson(this); -} - -@JsonSerializable() -class Live { - @JsonKey(name: "snapshot") - final Snapshot snapshot; - @JsonKey(name: "average_piece_download_time") - final AveragePieceDownloadTime? averagePieceDownloadTime; - @JsonKey(name: "download_speed") - final LoadSpeed downloadSpeed; - @JsonKey(name: "upload_speed") - final LoadSpeed uploadSpeed; - @JsonKey(name: "time_remaining") - final TimeRemaining? timeRemaining; - - Live({ - required this.snapshot, - this.averagePieceDownloadTime, - required this.downloadSpeed, - required this.uploadSpeed, - required this.timeRemaining, - }); - - Live copyWith({ - Snapshot? snapshot, - AveragePieceDownloadTime? averagePieceDownloadTime, - LoadSpeed? downloadSpeed, - LoadSpeed? uploadSpeed, - TimeRemaining? timeRemaining, - }) => - Live( - snapshot: snapshot ?? this.snapshot, - averagePieceDownloadTime: - averagePieceDownloadTime ?? this.averagePieceDownloadTime, - downloadSpeed: downloadSpeed ?? this.downloadSpeed, - uploadSpeed: uploadSpeed ?? this.uploadSpeed, - timeRemaining: timeRemaining ?? this.timeRemaining, - ); - - factory Live.fromJson(Map json) => _$LiveFromJson(json); - - Map toJson() => _$LiveToJson(this); -} - -@JsonSerializable() -class AveragePieceDownloadTime { - @JsonKey(name: "secs") - final int secs; - @JsonKey(name: "nanos") - final int nanos; - - AveragePieceDownloadTime({ - required this.secs, - required this.nanos, - }); - - AveragePieceDownloadTime copyWith({ - int? secs, - int? nanos, - }) => - AveragePieceDownloadTime( - secs: secs ?? this.secs, - nanos: nanos ?? this.nanos, - ); - - factory AveragePieceDownloadTime.fromJson(Map json) => - _$AveragePieceDownloadTimeFromJson(json); - - Map toJson() => _$AveragePieceDownloadTimeToJson(this); -} - -@JsonSerializable() -class LoadSpeed { - @JsonKey(name: "mbps") - final double mbps; - @JsonKey(name: "human_readable") - final String humanReadable; - - LoadSpeed({ - required this.mbps, - required this.humanReadable, - }); - - LoadSpeed copyWith({ - double? mbps, - String? humanReadable, - }) => - LoadSpeed( - mbps: mbps ?? this.mbps, - humanReadable: humanReadable ?? this.humanReadable, - ); - - factory LoadSpeed.fromJson(Map json) => - _$LoadSpeedFromJson(json); - - Map toJson() => _$LoadSpeedToJson(this); -} - -@JsonSerializable() -class Snapshot { - @JsonKey(name: "downloaded_and_checked_bytes") - final int downloadedAndCheckedBytes; - @JsonKey(name: "fetched_bytes") - final int fetchedBytes; - @JsonKey(name: "uploaded_bytes") - final int uploadedBytes; - @JsonKey(name: "downloaded_and_checked_pieces") - final int downloadedAndCheckedPieces; - @JsonKey(name: "total_piece_download_ms") - final int totalPieceDownloadMs; - @JsonKey(name: "peer_stats") - final PeerStats peerStats; - - Snapshot({ - required this.downloadedAndCheckedBytes, - required this.fetchedBytes, - required this.uploadedBytes, - required this.downloadedAndCheckedPieces, - required this.totalPieceDownloadMs, - required this.peerStats, - }); - - Snapshot copyWith({ - int? downloadedAndCheckedBytes, - int? fetchedBytes, - int? uploadedBytes, - int? downloadedAndCheckedPieces, - int? totalPieceDownloadMs, - PeerStats? peerStats, - }) => - Snapshot( - downloadedAndCheckedBytes: - downloadedAndCheckedBytes ?? this.downloadedAndCheckedBytes, - fetchedBytes: fetchedBytes ?? this.fetchedBytes, - uploadedBytes: uploadedBytes ?? this.uploadedBytes, - downloadedAndCheckedPieces: - downloadedAndCheckedPieces ?? this.downloadedAndCheckedPieces, - totalPieceDownloadMs: totalPieceDownloadMs ?? this.totalPieceDownloadMs, - peerStats: peerStats ?? this.peerStats, - ); - - factory Snapshot.fromJson(Map json) => - _$SnapshotFromJson(json); - - Map toJson() => _$SnapshotToJson(this); -} - -@JsonSerializable() -class PeerStats { - @JsonKey(name: "queued") - final int queued; - @JsonKey(name: "connecting") - final int connecting; - @JsonKey(name: "live") - final int live; - @JsonKey(name: "seen") - final int seen; - @JsonKey(name: "dead") - final int dead; - @JsonKey(name: "not_needed") - final int notNeeded; - @JsonKey(name: "steals") - final int steals; - - PeerStats({ - required this.queued, - required this.connecting, - required this.live, - required this.seen, - required this.dead, - required this.notNeeded, - required this.steals, - }); - - PeerStats copyWith({ - int? queued, - int? connecting, - int? live, - int? seen, - int? dead, - int? notNeeded, - int? steals, - }) => - PeerStats( - queued: queued ?? this.queued, - connecting: connecting ?? this.connecting, - live: live ?? this.live, - seen: seen ?? this.seen, - dead: dead ?? this.dead, - notNeeded: notNeeded ?? this.notNeeded, - steals: steals ?? this.steals, - ); - - factory PeerStats.fromJson(Map json) => - _$PeerStatsFromJson(json); - - Map toJson() => _$PeerStatsToJson(this); -} - -@JsonSerializable() -class TimeRemaining { - @JsonKey(name: "duration") - final AveragePieceDownloadTime duration; - @JsonKey(name: "human_readable") - final String humanReadable; - - TimeRemaining({ - required this.duration, - required this.humanReadable, - }); - - TimeRemaining copyWith({ - AveragePieceDownloadTime? duration, - String? humanReadable, - }) => - TimeRemaining( - duration: duration ?? this.duration, - humanReadable: humanReadable ?? this.humanReadable, - ); - - factory TimeRemaining.fromJson(Map json) => - _$TimeRemainingFromJson(json); - - Map toJson() => _$TimeRemainingToJson(this); -} diff --git a/lib/features/doc_viewer/container/video_viewer/trakt_integration.dart b/lib/features/doc_viewer/container/video_viewer/trakt_integration.dart deleted file mode 100644 index d6785bd..0000000 --- a/lib/features/doc_viewer/container/video_viewer/trakt_integration.dart +++ /dev/null @@ -1,13 +0,0 @@ -import 'package:media_kit/media_kit.dart'; - -class TraktIntegrationVideo { - Player player; - - TraktIntegrationVideo({ - required this.player, - }); - - initState() {} - - dispose() {} -} diff --git a/lib/features/doc_viewer/container/video_viewer/tv_controls.dart b/lib/features/doc_viewer/container/video_viewer/tv_controls.dart deleted file mode 100644 index 5161ba8..0000000 --- a/lib/features/doc_viewer/container/video_viewer/tv_controls.dart +++ /dev/null @@ -1,1525 +0,0 @@ -/// This file is a part of media_kit (https://github.com/media-kit/media-kit). -/// -/// Copyright © 2021 & onwards, Hitesh Kumar Saini . -/// All rights reserved. -/// Use of this source code is governed by MIT license that can be found in the LICENSE file. -// ignore_for_file: non_constant_identifier_names -import 'dart:async'; -import 'package:flutter/material.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/services.dart'; -import 'package:media_kit_video/media_kit_video.dart'; - -import 'package:media_kit_video/media_kit_video_controls/src/controls/methods/video_state.dart'; -import 'package:media_kit_video/media_kit_video_controls/src/controls/extensions/duration.dart'; -import 'package:media_kit_video/media_kit_video_controls/src/controls/widgets/video_controls_theme_data_injector.dart'; - -/// {@template material_desktop_video_controls} -/// -/// [Video] controls which use Material design. -/// -/// {@endtemplate} -Widget MaterialTvVideoControls(VideoState state) { - return const VideoControlsThemeDataInjector( - child: _MaterialTvVideoControls(), - ); -} - -/// [MaterialTvVideoControlsThemeData] available in this [context]. -MaterialTvVideoControlsThemeData _theme(BuildContext context) => - FullscreenInheritedWidget.maybeOf(context) == null - ? MaterialTvVideoControlsTheme.maybeOf(context)?.normal ?? - kDefaultMaterialDesktopVideoControlsThemeData - : MaterialTvVideoControlsTheme.maybeOf(context)?.fullscreen ?? - kDefaultMaterialDesktopVideoControlsThemeDataFullscreen; - -/// Default [MaterialTvVideoControlsThemeData]. -const kDefaultMaterialDesktopVideoControlsThemeData = -MaterialTvVideoControlsThemeData(); - -/// Default [MaterialTvVideoControlsThemeData] for fullscreen. -const kDefaultMaterialDesktopVideoControlsThemeDataFullscreen = -MaterialTvVideoControlsThemeData(); - -/// {@template material_desktop_video_controls_theme_data} -/// -/// Theming related data for [MaterialTvVideoControls]. These values are used to theme the descendant [MaterialTvVideoControls]. -/// -/// {@endtemplate} -class MaterialTvVideoControlsThemeData { - // BEHAVIOR - - /// Whether to display seek bar. - final bool displaySeekBar; - - /// Whether a skip next button should be displayed if there are more than one videos in the playlist. - final bool automaticallyImplySkipNextButton; - - /// Whether a skip previous button should be displayed if there are more than one videos in the playlist. - final bool automaticallyImplySkipPreviousButton; - - /// Modify volume on mouse scroll. - final bool modifyVolumeOnScroll; - - /// Whether to toggle fullscreen on double press. - final bool toggleFullscreenOnDoublePress; - - /// Whether to hide mouse on controls removal.(will need to move the mouse to be hidden check issue: https://github.com/flutter/flutter/issues/76622) works on macos without moving the mouse - final bool hideMouseOnControlsRemoval; - - /// Whether to toggle play and pause on tap. - final bool playAndPauseOnTap; - - /// Keyboards shortcuts. - final Map? keyboardShortcuts; - - /// Whether the controls are initially visible. - final bool visibleOnMount; - - // GENERIC - - /// Padding around the controls. - /// - /// * Default: `EdgeInsets.zero` - /// * Fullscreen: `MediaQuery.of(context).padding` - final EdgeInsets? padding; - - /// [Duration] after which the controls will be hidden when there is no mouse movement. - final Duration controlsHoverDuration; - - /// [Duration] for which the controls will be animated when shown or hidden. - final Duration controlsTransitionDuration; - - /// Builder for the buffering indicator. - final Widget Function(BuildContext)? bufferingIndicatorBuilder; - - // BUTTON BAR - - /// Buttons to be displayed in the primary button bar. - final List primaryButtonBar; - - /// Buttons to be displayed in the top button bar. - final List topButtonBar; - - /// Margin around the top button bar. - final EdgeInsets topButtonBarMargin; - - /// Buttons to be displayed in the bottom button bar. - final List bottomButtonBar; - - /// Margin around the bottom button bar. - final EdgeInsets bottomButtonBarMargin; - - /// Height of the button bar. - final double buttonBarHeight; - - /// Size of the button bar buttons. - final double buttonBarButtonSize; - - /// Color of the button bar buttons. - final Color buttonBarButtonColor; - - // SEEK BAR - - /// [Duration] for which the seek bar will be animated when the user seeks. - final Duration seekBarTransitionDuration; - - /// [Duration] for which the seek bar thumb will be animated when the user seeks. - final Duration seekBarThumbTransitionDuration; - - /// Margin around the seek bar. - final EdgeInsets seekBarMargin; - - /// Height of the seek bar. - final double seekBarHeight; - - /// Height of the seek bar when hovered. - final double seekBarHoverHeight; - - /// Height of the seek bar [Container]. - final double seekBarContainerHeight; - - /// [Color] of the seek bar. - final Color seekBarColor; - - /// [Color] of the hovered section in the seek bar. - final Color seekBarHoverColor; - - /// [Color] of the playback position section in the seek bar. - final Color seekBarPositionColor; - - /// [Color] of the playback buffer section in the seek bar. - final Color seekBarBufferColor; - - /// Size of the seek bar thumb. - final double seekBarThumbSize; - - /// [Color] of the seek bar thumb. - final Color seekBarThumbColor; - - // VOLUME BAR - - /// [Color] of the volume bar. - final Color volumeBarColor; - - /// [Color] of the active region in the volume bar. - final Color volumeBarActiveColor; - - /// Size of the volume bar thumb. - final double volumeBarThumbSize; - - /// [Color] of the volume bar thumb. - final Color volumeBarThumbColor; - - /// [Duration] for which the volume bar will be animated when the user hovers. - final Duration volumeBarTransitionDuration; - - // SUBTITLE - - /// Whether to shift the subtitles upwards when the controls are visible. - final bool shiftSubtitlesOnControlsVisibilityChange; - - /// {@macro material_desktop_video_controls_theme_data} - const MaterialTvVideoControlsThemeData({ - this.displaySeekBar = true, - this.automaticallyImplySkipNextButton = true, - this.automaticallyImplySkipPreviousButton = true, - this.toggleFullscreenOnDoublePress = true, - this.playAndPauseOnTap = false, - this.modifyVolumeOnScroll = true, - this.keyboardShortcuts, - this.visibleOnMount = false, - this.hideMouseOnControlsRemoval = false, - this.padding, - this.controlsHoverDuration = const Duration(seconds: 3), - this.controlsTransitionDuration = const Duration(milliseconds: 150), - this.bufferingIndicatorBuilder, - this.primaryButtonBar = const [], - this.topButtonBar = const [], - this.topButtonBarMargin = const EdgeInsets.symmetric(horizontal: 16.0), - this.bottomButtonBar = const [ - MaterialTvSkipPreviousButton(), - MaterialTvPlayOrPauseButton(), - MaterialTvSkipNextButton(), - MaterialTvVolumeButton(), - MaterialTvPositionIndicator(), - Spacer(), - MaterialTvFullscreenButton(), - ], - this.bottomButtonBarMargin = const EdgeInsets.symmetric(horizontal: 16.0), - this.buttonBarHeight = 56.0, - this.buttonBarButtonSize = 28.0, - this.buttonBarButtonColor = const Color(0xFFFFFFFF), - this.seekBarTransitionDuration = const Duration(milliseconds: 300), - this.seekBarThumbTransitionDuration = const Duration(milliseconds: 150), - this.seekBarMargin = const EdgeInsets.symmetric(horizontal: 16.0), - this.seekBarHeight = 3.2, - this.seekBarHoverHeight = 5.6, - this.seekBarContainerHeight = 36.0, - this.seekBarColor = const Color(0x3DFFFFFF), - this.seekBarHoverColor = const Color(0x3DFFFFFF), - this.seekBarPositionColor = const Color(0xFFFF0000), - this.seekBarBufferColor = const Color(0x3DFFFFFF), - this.seekBarThumbSize = 12.0, - this.seekBarThumbColor = const Color(0xFFFF0000), - this.volumeBarColor = const Color(0x3DFFFFFF), - this.volumeBarActiveColor = const Color(0xFFFFFFFF), - this.volumeBarThumbSize = 12.0, - this.volumeBarThumbColor = const Color(0xFFFFFFFF), - this.volumeBarTransitionDuration = const Duration(milliseconds: 150), - this.shiftSubtitlesOnControlsVisibilityChange = true, - }); - - /// Creates a copy of this [MaterialTvVideoControlsThemeData] with the given fields replaced by the non-null parameter values. - MaterialTvVideoControlsThemeData copyWith({ - bool? displaySeekBar, - bool? automaticallyImplySkipNextButton, - bool? automaticallyImplySkipPreviousButton, - bool? toggleFullscreenOnDoublePress, - bool? playAndPauseOnTap, - bool? modifyVolumeOnScroll, - Map? keyboardShortcuts, - bool? visibleOnMount, - bool? hideMouseOnControlsRemoval, - Duration? controlsHoverDuration, - Duration? controlsTransitionDuration, - Widget Function(BuildContext)? bufferingIndicatorBuilder, - List? topButtonBar, - EdgeInsets? topButtonBarMargin, - List? bottomButtonBar, - EdgeInsets? bottomButtonBarMargin, - double? buttonBarHeight, - double? buttonBarButtonSize, - Color? buttonBarButtonColor, - Duration? seekBarTransitionDuration, - Duration? seekBarThumbTransitionDuration, - EdgeInsets? seekBarMargin, - double? seekBarHeight, - double? seekBarHoverHeight, - double? seekBarContainerHeight, - Color? seekBarColor, - Color? seekBarHoverColor, - Color? seekBarPositionColor, - Color? seekBarBufferColor, - double? seekBarThumbSize, - Color? seekBarThumbColor, - Color? volumeBarColor, - Color? volumeBarActiveColor, - double? volumeBarThumbSize, - Color? volumeBarThumbColor, - Duration? volumeBarTransitionDuration, - bool? shiftSubtitlesOnControlsVisibilityChange, - }) { - return MaterialTvVideoControlsThemeData( - displaySeekBar: displaySeekBar ?? this.displaySeekBar, - automaticallyImplySkipNextButton: automaticallyImplySkipNextButton ?? - this.automaticallyImplySkipNextButton, - automaticallyImplySkipPreviousButton: - automaticallyImplySkipPreviousButton ?? - this.automaticallyImplySkipPreviousButton, - toggleFullscreenOnDoublePress: - toggleFullscreenOnDoublePress ?? this.toggleFullscreenOnDoublePress, - playAndPauseOnTap: playAndPauseOnTap ?? this.playAndPauseOnTap, - modifyVolumeOnScroll: modifyVolumeOnScroll ?? this.modifyVolumeOnScroll, - keyboardShortcuts: keyboardShortcuts ?? this.keyboardShortcuts, - visibleOnMount: visibleOnMount ?? this.visibleOnMount, - hideMouseOnControlsRemoval: - hideMouseOnControlsRemoval ?? this.hideMouseOnControlsRemoval, - controlsHoverDuration: - controlsHoverDuration ?? this.controlsHoverDuration, - bufferingIndicatorBuilder: - bufferingIndicatorBuilder ?? this.bufferingIndicatorBuilder, - controlsTransitionDuration: - controlsTransitionDuration ?? this.controlsTransitionDuration, - topButtonBar: topButtonBar ?? this.topButtonBar, - topButtonBarMargin: topButtonBarMargin ?? this.topButtonBarMargin, - bottomButtonBar: bottomButtonBar ?? this.bottomButtonBar, - bottomButtonBarMargin: - bottomButtonBarMargin ?? this.bottomButtonBarMargin, - buttonBarHeight: buttonBarHeight ?? this.buttonBarHeight, - buttonBarButtonSize: buttonBarButtonSize ?? this.buttonBarButtonSize, - buttonBarButtonColor: buttonBarButtonColor ?? this.buttonBarButtonColor, - seekBarTransitionDuration: - seekBarTransitionDuration ?? this.seekBarTransitionDuration, - seekBarThumbTransitionDuration: - seekBarThumbTransitionDuration ?? this.seekBarThumbTransitionDuration, - seekBarMargin: seekBarMargin ?? this.seekBarMargin, - seekBarHeight: seekBarHeight ?? this.seekBarHeight, - seekBarHoverHeight: seekBarHoverHeight ?? this.seekBarHoverHeight, - seekBarContainerHeight: - seekBarContainerHeight ?? this.seekBarContainerHeight, - seekBarColor: seekBarColor ?? this.seekBarColor, - seekBarHoverColor: seekBarHoverColor ?? this.seekBarHoverColor, - seekBarPositionColor: seekBarPositionColor ?? this.seekBarPositionColor, - seekBarBufferColor: seekBarBufferColor ?? this.seekBarBufferColor, - seekBarThumbSize: seekBarThumbSize ?? this.seekBarThumbSize, - seekBarThumbColor: seekBarThumbColor ?? this.seekBarThumbColor, - volumeBarColor: volumeBarColor ?? this.volumeBarColor, - volumeBarActiveColor: volumeBarActiveColor ?? this.volumeBarActiveColor, - volumeBarThumbSize: volumeBarThumbSize ?? this.volumeBarThumbSize, - volumeBarThumbColor: volumeBarThumbColor ?? this.volumeBarThumbColor, - volumeBarTransitionDuration: - volumeBarTransitionDuration ?? this.volumeBarTransitionDuration, - shiftSubtitlesOnControlsVisibilityChange: - shiftSubtitlesOnControlsVisibilityChange ?? - this.shiftSubtitlesOnControlsVisibilityChange, - ); - } -} - -/// {@template material_desktop_video_controls_theme} -/// -/// Inherited widget which provides [MaterialTvVideoControlsThemeData] to descendant widgets. -/// -/// {@endtemplate} -class MaterialTvVideoControlsTheme extends InheritedWidget { - final MaterialTvVideoControlsThemeData normal; - final MaterialTvVideoControlsThemeData fullscreen; - const MaterialTvVideoControlsTheme({ - super.key, - required this.normal, - required this.fullscreen, - required super.child, - }); - - static MaterialTvVideoControlsTheme? maybeOf(BuildContext context) { - return context - .dependOnInheritedWidgetOfExactType(); - } - - static MaterialTvVideoControlsTheme of(BuildContext context) { - final MaterialTvVideoControlsTheme? result = maybeOf(context); - assert( - result != null, - 'No [MaterialDesktopVideoControlsTheme] found in [context]', - ); - return result!; - } - - @override - bool updateShouldNotify(MaterialTvVideoControlsTheme oldWidget) => - identical(normal, oldWidget.normal) && - identical(fullscreen, oldWidget.fullscreen); -} - -/// {@macro material_desktop_video_controls} -class _MaterialTvVideoControls extends StatefulWidget { - const _MaterialTvVideoControls(); - - @override - State<_MaterialTvVideoControls> createState() => - _MaterialTvVideoControlsState(); -} - -/// {@macro material_desktop_video_controls} -class _MaterialTvVideoControlsState extends State<_MaterialTvVideoControls> { - late bool mount = _theme(context).visibleOnMount; - late bool visible = _theme(context).visibleOnMount; - - Timer? _timer; - - late /* private */ var playlist = controller(context).player.state.playlist; - late bool buffering = controller(context).player.state.buffering; - - DateTime last = DateTime.now(); - - final List subscriptions = []; - - FocusNode _focusNode = FocusNode(); - - double get subtitleVerticalShiftOffset => - (_theme(context).padding?.bottom ?? 0.0) + - (_theme(context).bottomButtonBarMargin.vertical) + - (_theme(context).bottomButtonBar.isNotEmpty - ? _theme(context).buttonBarHeight - : 0.0); - - @override - void setState(VoidCallback fn) { - if (mounted) { - super.setState(fn); - } - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - if (subscriptions.isEmpty) { - subscriptions.addAll( - [ - controller(context).player.stream.playlist.listen( - (event) { - setState(() { - playlist = event; - }); - }, - ), - controller(context).player.stream.buffering.listen( - (event) { - setState(() { - buffering = event; - }); - }, - ), - ], - ); - - if (_theme(context).visibleOnMount) { - _timer = Timer( - _theme(context).controlsHoverDuration, - () { - if (mounted) { - setState(() { - visible = false; - }); - unshiftSubtitle(); - } - }, - ); - } - } - } - - @override - void dispose() { - for (final subscription in subscriptions) { - subscription.cancel(); - } - super.dispose(); - } - - void shiftSubtitle() { - if (_theme(context).shiftSubtitlesOnControlsVisibilityChange) { - state(context).setSubtitleViewPadding( - state(context).widget.subtitleViewConfiguration.padding + - EdgeInsets.fromLTRB( - 0.0, - 0.0, - 0.0, - subtitleVerticalShiftOffset, - ), - ); - } - } - - void unshiftSubtitle() { - if (_theme(context).shiftSubtitlesOnControlsVisibilityChange) { - state(context).setSubtitleViewPadding( - state(context).widget.subtitleViewConfiguration.padding, - ); - } - } - - void onHover() { - setState(() { - mount = true; - visible = true; - }); - shiftSubtitle(); - _timer?.cancel(); - _timer = Timer(_theme(context).controlsHoverDuration, () { - if (mounted) { - setState(() { - visible = false; - }); - unshiftSubtitle(); - } - }); - } - - void onEnter() { - setState(() { - mount = true; - visible = true; - }); - shiftSubtitle(); - _timer?.cancel(); - _timer = Timer(_theme(context).controlsHoverDuration, () { - if (mounted) { - setState(() { - visible = false; - }); - unshiftSubtitle(); - } - }); - } - - void onExit() { - setState(() { - visible = false; - }); - unshiftSubtitle(); - _timer?.cancel(); - } - - @override - Widget build(BuildContext context) { - return FocusScope( - autofocus: true, - onKeyEvent: (node, event) { - onEnter(); - - if (event is KeyDownEvent) { - print('Key pressed: ${event.logicalKey.debugName}'); - - if (event.logicalKey == LogicalKeyboardKey.mediaPlayPause) { - controller(context).player.playOrPause(); - } - } - - return KeyEventResult.ignored; - }, - child: Theme( - data: Theme.of(context).copyWith( - focusColor: const Color(0x00000000), - hoverColor: const Color(0x00000000), - splashColor: const Color(0x00000000), - highlightColor: const Color(0x00000000), - ), - child: Material( - elevation: 0.0, - borderOnForeground: false, - animationDuration: Duration.zero, - color: const Color(0x00000000), - shadowColor: const Color(0x00000000), - surfaceTintColor: const Color(0x00000000), - child: Stack( - children: [ - AnimatedOpacity( - curve: Curves.easeInOut, - opacity: visible ? 1.0 : 0.0, - duration: _theme(context).controlsTransitionDuration, - onEnd: () { - if (!visible) { - setState(() { - mount = false; - }); - } - }, - child: Stack( - clipBehavior: Clip.none, - alignment: Alignment.bottomCenter, - children: [ - // Top gradient. - if (_theme(context).topButtonBar.isNotEmpty) - Container( - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - stops: [ - 0.0, - 0.2, - ], - colors: [ - Color(0x61000000), - Color(0x00000000), - ], - ), - ), - ), - // Bottom gradient. - if (_theme(context).bottomButtonBar.isNotEmpty) - Container( - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - stops: [ - 0.5, - 1.0, - ], - colors: [ - Color(0x00000000), - Color(0x61000000), - ], - ), - ), - ), - if (mount) - Padding( - padding: _theme(context).padding ?? - ( - // Add padding in fullscreen! - isFullscreen(context) - ? MediaQuery.of(context).padding - : EdgeInsets.zero), - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Container( - height: _theme(context).buttonBarHeight, - margin: _theme(context).topButtonBarMargin, - child: Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.center, - children: _theme(context).topButtonBar, - ), - ), - // Only display [primaryButtonBar] if [buffering] is false. - Expanded( - child: AnimatedOpacity( - curve: Curves.easeInOut, - opacity: buffering ? 0.0 : 1.0, - duration: - _theme(context).controlsTransitionDuration, - child: Center( - child: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: - CrossAxisAlignment.center, - children: _theme(context).primaryButtonBar, - ), - ), - ), - ), - if (_theme(context).displaySeekBar) - Transform.translate( - offset: - _theme(context).bottomButtonBar.isNotEmpty - ? const Offset(0.0, 16.0) - : Offset.zero, - child: MaterialTvSeekBar( - onSeekStart: () { - _timer?.cancel(); - }, - onSeekEnd: () { - _timer = Timer( - _theme(context).controlsHoverDuration, - () { - if (mounted) { - setState(() { - visible = false; - }); - unshiftSubtitle(); - } - }, - ); - }, - ), - ), - if (_theme(context).bottomButtonBar.isNotEmpty) - Container( - height: _theme(context).buttonBarHeight, - margin: _theme(context).bottomButtonBarMargin, - child: Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.center, - children: _theme(context).bottomButtonBar, - ), - ), - ], - ), - ), - ], - ), - ), - // Buffering Indicator. - IgnorePointer( - child: Padding( - padding: _theme(context).padding ?? - ( - // Add padding in fullscreen! - isFullscreen(context) - ? MediaQuery.of(context).padding - : EdgeInsets.zero), - child: Column( - children: [ - Container( - height: _theme(context).buttonBarHeight, - margin: _theme(context).topButtonBarMargin, - ), - Expanded( - child: Center( - child: Center( - child: TweenAnimationBuilder( - tween: Tween( - begin: 0.0, - end: buffering ? 1.0 : 0.0, - ), - duration: - _theme(context).controlsTransitionDuration, - builder: (context, value, child) { - // Only mount the buffering indicator if the opacity is greater than 0.0. - // This has been done to prevent redundant resource usage in [CircularProgressIndicator]. - if (value > 0.0) { - return Opacity( - opacity: value, - child: _theme(context) - .bufferingIndicatorBuilder - ?.call(context) ?? - child!, - ); - } - return const SizedBox.shrink(); - }, - child: const CircularProgressIndicator( - color: Color(0xFFFFFFFF), - ), - ), - ), - ), - ), - Container( - height: _theme(context).buttonBarHeight, - margin: _theme(context).bottomButtonBarMargin, - ), - ], - ), - ), - ), - ], - ), - ), - ), - ); - } -} - -// SEEK BAR - -/// Material design seek bar. -class MaterialTvSeekBar extends StatefulWidget { - final VoidCallback? onSeekStart; - final VoidCallback? onSeekEnd; - - const MaterialTvSeekBar({ - Key? key, - this.onSeekStart, - this.onSeekEnd, - }) : super(key: key); - - @override - MaterialTvSeekBarState createState() => MaterialTvSeekBarState(); -} - -class MaterialTvSeekBarState extends State { - bool hover = false; - bool click = false; - double slider = 0.0; - - late bool playing = controller(context).player.state.playing; - late Duration position = controller(context).player.state.position; - late Duration duration = controller(context).player.state.duration; - late Duration buffer = controller(context).player.state.buffer; - - final List subscriptions = []; - - @override - void setState(VoidCallback fn) { - if (mounted) { - super.setState(fn); - } - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - if (subscriptions.isEmpty) { - subscriptions.addAll( - [ - controller(context).player.stream.playing.listen((event) { - setState(() { - playing = event; - }); - }), - controller(context).player.stream.completed.listen((event) { - setState(() { - position = Duration.zero; - }); - }), - controller(context).player.stream.position.listen((event) { - setState(() { - if (!click) position = event; - }); - }), - controller(context).player.stream.duration.listen((event) { - setState(() { - duration = event; - }); - }), - controller(context).player.stream.buffer.listen((event) { - setState(() { - buffer = event; - }); - }), - ], - ); - } - } - - @override - void dispose() { - for (final subscription in subscriptions) { - subscription.cancel(); - } - super.dispose(); - } - - void onPointerMove(PointerMoveEvent e, BoxConstraints constraints) { - final percent = e.localPosition.dx / constraints.maxWidth; - setState(() { - hover = true; - slider = percent.clamp(0.0, 1.0); - }); - controller(context).player.seek(duration * slider); - } - - void onPointerDown() { - widget.onSeekStart?.call(); - setState(() { - click = true; - }); - } - - void onPointerUp() { - widget.onSeekEnd?.call(); - setState(() { - // Explicitly set the position to prevent the slider from jumping. - click = false; - position = duration * slider; - }); - controller(context).player.seek(duration * slider); - } - - void onHover(PointerHoverEvent e, BoxConstraints constraints) { - final percent = e.localPosition.dx / constraints.maxWidth; - setState(() { - hover = true; - slider = percent.clamp(0.0, 1.0); - }); - } - - void onEnter(PointerEnterEvent e, BoxConstraints constraints) { - final percent = e.localPosition.dx / constraints.maxWidth; - setState(() { - hover = true; - slider = percent.clamp(0.0, 1.0); - }); - } - - void onExit(PointerExitEvent e, BoxConstraints constraints) { - setState(() { - hover = false; - slider = 0.0; - }); - } - - /// Returns the current playback position in percentage. - double get positionPercent { - if (position == Duration.zero || duration == Duration.zero) { - return 0.0; - } else { - final value = position.inMilliseconds / duration.inMilliseconds; - return value.clamp(0.0, 1.0); - } - } - - /// Returns the current playback buffer position in percentage. - double get bufferPercent { - if (buffer == Duration.zero || duration == Duration.zero) { - return 0.0; - } else { - final value = buffer.inMilliseconds / duration.inMilliseconds; - return value.clamp(0.0, 1.0); - } - } - - FocusNode focusNode = FocusNode(); - FocusNode focusNode2 = FocusNode(); - - @override - Widget build(BuildContext context) { - return FocusableActionDetector( - focusNode: focusNode, // Create a FocusNode to manage focus - autofocus: false, // Automatically focus when the widget appears - onFocusChange: (focused) { - // Handle focus changes - setState(() { - hover = focused; // Example: show hover effect when focused - }); - - if (focused) { - focusNode2.requestFocus(); - } - }, - child: Focus( - focusNode: focusNode2, - onKeyEvent: (node, event) { - if (event is KeyDownEvent) { - if (event.logicalKey == LogicalKeyboardKey.arrowRight) { - double percent = 0.01; - - double sliderPercent = - (positionPercent + percent).clamp(0.0, 1.0); - - setState(() { - hover = true; - slider = sliderPercent; - }); - controller(context).player.seek(duration * slider); - - return KeyEventResult.handled; - } else if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { - double percent = 0.01; - - double sliderPercent = - (positionPercent - percent).clamp(0.0, 1.0); - - setState(() { - hover = true; - slider = sliderPercent; - }); - controller(context).player.seek(duration * slider); - - return KeyEventResult.handled; - } - } - - return KeyEventResult.ignored; - }, - child: Container( - clipBehavior: Clip.none, - margin: _theme(context).seekBarMargin, - child: LayoutBuilder( - builder: (context, constraints) => Container( - color: const Color(0x00000000), - width: constraints.maxWidth, - height: _theme(context).seekBarContainerHeight, - child: Stack( - clipBehavior: Clip.none, - alignment: Alignment.centerLeft, - children: [ - AnimatedContainer( - width: constraints.maxWidth, - height: hover - ? _theme(context).seekBarHoverHeight - : _theme(context).seekBarHeight, - alignment: Alignment.centerLeft, - duration: _theme(context).seekBarThumbTransitionDuration, - color: _theme(context).seekBarColor, - child: Stack( - clipBehavior: Clip.none, - alignment: Alignment.centerLeft, - children: [ - Container( - width: constraints.maxWidth * slider, - color: _theme(context).seekBarHoverColor, - ), - Container( - width: constraints.maxWidth * bufferPercent, - color: _theme(context).seekBarBufferColor, - ), - Container( - width: click - ? constraints.maxWidth * slider - : constraints.maxWidth * positionPercent, - color: _theme(context).seekBarPositionColor, - ), - ], - ), - ), - Positioned( - left: click - ? (constraints.maxWidth - - _theme(context).seekBarThumbSize / 2) * - slider - : (constraints.maxWidth - - _theme(context).seekBarThumbSize / 2) * - positionPercent, - child: AnimatedContainer( - width: hover || click - ? _theme(context).seekBarThumbSize - : 0.0, - height: hover || click - ? _theme(context).seekBarThumbSize - : 0.0, - duration: _theme(context).seekBarThumbTransitionDuration, - decoration: BoxDecoration( - color: _theme(context).seekBarThumbColor, - borderRadius: BorderRadius.circular( - _theme(context).seekBarThumbSize / 2, - ), - ), - ), - ), - ], - ), - ), - ), - ), - ), - ); - } -} - -// BUTTON: PLAY/PAUSE - -/// A material design play/pause button. -class MaterialTvPlayOrPauseButton extends StatefulWidget { - /// Overriden icon size for [MaterialTvSkipPreviousButton]. - final double? iconSize; - - /// Overriden icon color for [MaterialTvSkipPreviousButton]. - final Color? iconColor; - - const MaterialTvPlayOrPauseButton({ - super.key, - this.iconSize, - this.iconColor, - }); - - @override - MaterialTvPlayOrPauseButtonState createState() => - MaterialTvPlayOrPauseButtonState(); -} - -class MaterialTvPlayOrPauseButtonState - extends State - with SingleTickerProviderStateMixin { - late final animation = AnimationController( - vsync: this, - value: controller(context).player.state.playing ? 1 : 0, - duration: const Duration(milliseconds: 200), - ); - - StreamSubscription? subscription; - - @override - void setState(VoidCallback fn) { - if (mounted) { - super.setState(fn); - } - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - subscription ??= controller(context).player.stream.playing.listen((event) { - if (event) { - animation.forward(); - } else { - animation.reverse(); - } - }); - } - - @override - void dispose() { - animation.dispose(); - subscription?.cancel(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return IconButton( - onPressed: controller(context).player.playOrPause, - iconSize: widget.iconSize ?? _theme(context).buttonBarButtonSize, - color: widget.iconColor ?? _theme(context).buttonBarButtonColor, - icon: AnimatedIcon( - progress: animation, - icon: AnimatedIcons.play_pause, - size: widget.iconSize ?? _theme(context).buttonBarButtonSize, - color: widget.iconColor ?? _theme(context).buttonBarButtonColor, - ), - ); - } -} - -// BUTTON: SKIP NEXT - -/// MaterialDesktop design skip next button. -class MaterialTvSkipNextButton extends StatelessWidget { - /// Icon for [MaterialTvSkipNextButton]. - final Widget? icon; - - /// Overriden icon size for [MaterialTvSkipNextButton]. - final double? iconSize; - - /// Overriden icon color for [MaterialTvSkipNextButton]. - final Color? iconColor; - - const MaterialTvSkipNextButton({ - Key? key, - this.icon, - this.iconSize, - this.iconColor, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - if (!_theme(context).automaticallyImplySkipNextButton || - (controller(context).player.state.playlist.medias.length > 1 && - _theme(context).automaticallyImplySkipNextButton)) { - return IconButton( - onPressed: controller(context).player.next, - icon: icon ?? const Icon(Icons.skip_next), - iconSize: iconSize ?? _theme(context).buttonBarButtonSize, - color: iconColor ?? _theme(context).buttonBarButtonColor, - ); - } - return const SizedBox.shrink(); - } -} - -// BUTTON: SKIP PREVIOUS - -/// MaterialDesktop design skip previous button. -class MaterialTvSkipPreviousButton extends StatelessWidget { - /// Icon for [MaterialTvSkipPreviousButton]. - final Widget? icon; - - /// Overriden icon size for [MaterialTvSkipPreviousButton]. - final double? iconSize; - - /// Overriden icon color for [MaterialTvSkipPreviousButton]. - final Color? iconColor; - - const MaterialTvSkipPreviousButton({ - Key? key, - this.icon, - this.iconSize, - this.iconColor, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - if (!_theme(context).automaticallyImplySkipPreviousButton || - (controller(context).player.state.playlist.medias.length > 1 && - _theme(context).automaticallyImplySkipPreviousButton)) { - return IconButton( - onPressed: controller(context).player.previous, - icon: icon ?? const Icon(Icons.skip_previous), - iconSize: iconSize ?? _theme(context).buttonBarButtonSize, - color: iconColor ?? _theme(context).buttonBarButtonColor, - ); - } - return const SizedBox.shrink(); - } -} - -// BUTTON: FULL SCREEN - -/// MaterialDesktop design fullscreen button. -class MaterialTvFullscreenButton extends StatelessWidget { - /// Icon for [MaterialTvFullscreenButton]. - final Widget? icon; - - /// Overriden icon size for [MaterialTvFullscreenButton]. - final double? iconSize; - - /// Overriden icon color for [MaterialTvFullscreenButton]. - final Color? iconColor; - - const MaterialTvFullscreenButton({ - Key? key, - this.icon, - this.iconSize, - this.iconColor, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return IconButton( - onPressed: () => toggleFullscreen(context), - icon: icon ?? - (isFullscreen(context) - ? const Icon(Icons.fullscreen_exit) - : const Icon(Icons.fullscreen)), - iconSize: iconSize ?? _theme(context).buttonBarButtonSize, - color: iconColor ?? _theme(context).buttonBarButtonColor, - ); - } -} - -// BUTTON: CUSTOM - -/// MaterialDesktop design custom button. -class MaterialTvCustomButton extends StatelessWidget { - /// Icon for [MaterialTvCustomButton]. - final Widget? icon; - - /// Icon size for [MaterialTvCustomButton]. - final double? iconSize; - - /// Icon color for [MaterialTvCustomButton]. - final Color? iconColor; - - /// The callback that is called when the button is tapped or otherwise activated. - final VoidCallback onPressed; - - const MaterialTvCustomButton({ - Key? key, - this.icon, - this.iconSize, - this.iconColor, - required this.onPressed, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return IconButton( - onPressed: onPressed, - icon: icon ?? const Icon(Icons.settings), - iconSize: iconSize ?? _theme(context).buttonBarButtonSize, - color: iconColor ?? _theme(context).buttonBarButtonColor, - ); - } -} - -// BUTTON: VOLUME - -/// MaterialDesktop design volume button & slider. -class MaterialTvVolumeButton extends StatefulWidget { - /// Icon size for the volume button. - final double? iconSize; - - /// Icon color for the volume button. - final Color? iconColor; - - /// Mute icon. - final Widget? volumeMuteIcon; - - /// Low volume icon. - final Widget? volumeLowIcon; - - /// High volume icon. - final Widget? volumeHighIcon; - - /// Width for the volume slider. - final double? sliderWidth; - - const MaterialTvVolumeButton({ - super.key, - this.iconSize, - this.iconColor, - this.volumeMuteIcon, - this.volumeLowIcon, - this.volumeHighIcon, - this.sliderWidth, - }); - - @override - MaterialTvVolumeButtonState createState() => MaterialTvVolumeButtonState(); -} - -class MaterialTvVolumeButtonState extends State - with SingleTickerProviderStateMixin { - late double volume = controller(context).player.state.volume; - - StreamSubscription? subscription; - - bool hover = false; - - bool mute = false; - double _volume = 0.0; - - @override - void setState(VoidCallback fn) { - if (mounted) { - super.setState(fn); - } - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - subscription ??= controller(context).player.stream.volume.listen((event) { - setState(() { - volume = event; - }); - }); - } - - @override - void dispose() { - subscription?.cancel(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return MouseRegion( - onEnter: (e) { - setState(() { - hover = true; - }); - }, - onExit: (e) { - setState(() { - hover = false; - }); - }, - child: Listener( - onPointerSignal: (event) { - if (event is PointerScrollEvent) { - if (event.scrollDelta.dy < 0) { - controller(context).player.setVolume( - (volume + 5.0).clamp(0.0, 100.0), - ); - } - if (event.scrollDelta.dy > 0) { - controller(context).player.setVolume( - (volume - 5.0).clamp(0.0, 100.0), - ); - } - } - }, - child: Row( - children: [ - const SizedBox(width: 4.0), - IconButton( - onPressed: () async { - if (mute) { - await controller(context).player.setVolume(_volume); - mute = !mute; - } - // https://github.com/media-kit/media-kit/pull/250#issuecomment-1605588306 - else if (volume == 0.0) { - _volume = 100.0; - await controller(context).player.setVolume(100.0); - mute = false; - } else { - _volume = volume; - await controller(context).player.setVolume(0.0); - mute = !mute; - } - - setState(() {}); - }, - iconSize: widget.iconSize ?? - (_theme(context).buttonBarButtonSize * 0.8), - color: widget.iconColor ?? _theme(context).buttonBarButtonColor, - icon: AnimatedSwitcher( - duration: _theme(context).volumeBarTransitionDuration, - child: volume == 0.0 - ? (widget.volumeMuteIcon ?? - const Icon( - Icons.volume_off, - key: ValueKey(Icons.volume_off), - )) - : volume < 50.0 - ? (widget.volumeLowIcon ?? - const Icon( - Icons.volume_down, - key: ValueKey(Icons.volume_down), - )) - : (widget.volumeHighIcon ?? - const Icon( - Icons.volume_up, - key: ValueKey(Icons.volume_up), - )), - ), - ), - AnimatedOpacity( - opacity: hover ? 1.0 : 0.0, - duration: _theme(context).volumeBarTransitionDuration, - child: AnimatedContainer( - width: - hover ? (12.0 + (widget.sliderWidth ?? 52.0) + 18.0) : 12.0, - duration: _theme(context).volumeBarTransitionDuration, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - const SizedBox(width: 12.0), - SizedBox( - width: widget.sliderWidth ?? 52.0, - child: SliderTheme( - data: SliderThemeData( - trackHeight: 1.2, - inactiveTrackColor: _theme(context).volumeBarColor, - activeTrackColor: - _theme(context).volumeBarActiveColor, - thumbColor: _theme(context).volumeBarThumbColor, - thumbShape: RoundSliderThumbShape( - enabledThumbRadius: - _theme(context).volumeBarThumbSize / 2, - elevation: 0.0, - pressedElevation: 0.0, - ), - trackShape: _CustomTrackShape(), - overlayColor: const Color(0x00000000), - ), - child: Slider( - value: volume.clamp(0.0, 100.0), - min: 0.0, - max: 100.0, - onChanged: (value) async { - await controller(context).player.setVolume(value); - mute = false; - setState(() {}); - }, - ), - ), - ), - const SizedBox(width: 18.0), - ], - ), - ), - ), - ), - ], - ), - ), - ); - } -} - -// POSITION INDICATOR - -/// MaterialDesktop design position indicator. -class MaterialTvPositionIndicator extends StatefulWidget { - /// Overriden [TextStyle] for the [MaterialTvPositionIndicator]. - final TextStyle? style; - const MaterialTvPositionIndicator({super.key, this.style}); - - @override - MaterialTvPositionIndicatorState createState() => - MaterialTvPositionIndicatorState(); -} - -class MaterialTvPositionIndicatorState - extends State { - late Duration position = controller(context).player.state.position; - late Duration duration = controller(context).player.state.duration; - - final List subscriptions = []; - - @override - void setState(VoidCallback fn) { - if (mounted) { - super.setState(fn); - } - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - if (subscriptions.isEmpty) { - subscriptions.addAll( - [ - controller(context).player.stream.position.listen((event) { - setState(() { - position = event; - }); - }), - controller(context).player.stream.duration.listen((event) { - setState(() { - duration = event; - }); - }), - ], - ); - } - } - - @override - void dispose() { - for (final subscription in subscriptions) { - subscription.cancel(); - } - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Text( - '${position.label(reference: duration)} / ${duration.label(reference: duration)}', - style: widget.style ?? - TextStyle( - height: 1.0, - fontSize: 12.0, - color: _theme(context).buttonBarButtonColor, - ), - ); - } -} - -class _CustomTrackShape extends RoundedRectSliderTrackShape { - @override - Rect getPreferredRect({ - required RenderBox parentBox, - Offset offset = Offset.zero, - required SliderThemeData sliderTheme, - bool isEnabled = false, - bool isDiscrete = false, - }) { - final height = sliderTheme.trackHeight; - final left = offset.dx; - final top = offset.dy + (parentBox.size.height - height!) / 2; - final width = parentBox.size.width; - return Rect.fromLTWH( - left, - top, - width, - height, - ); - } -} diff --git a/lib/features/doc_viewer/container/video_viewer/video_viewer_mobile_ui.dart b/lib/features/doc_viewer/container/video_viewer/video_viewer_mobile_ui.dart deleted file mode 100644 index 4d3464f..0000000 --- a/lib/features/doc_viewer/container/video_viewer/video_viewer_mobile_ui.dart +++ /dev/null @@ -1,276 +0,0 @@ -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:logging/logging.dart'; -import 'package:madari_client/features/connections/service/base_connection_service.dart'; -import 'package:madari_client/features/doc_viewer/container/video_viewer/season_source.dart'; -import 'package:madari_client/features/doc_viewer/container/video_viewer/torrent_stat.dart'; -import 'package:madari_client/features/doc_viewer/types/doc_source.dart'; -import 'package:madari_client/utils/load_language.dart'; -import 'package:media_kit/media_kit.dart'; -import 'package:media_kit_video/media_kit_video.dart'; - -import '../../../connections/types/stremio/stremio_base.types.dart' as types; - -class VideoViewerMobile extends StatefulWidget { - final VoidCallback onSubtitleSelect; - final VoidCallback onLibrarySelect; - final Player player; - final DocSource source; - final VideoController controller; - final VoidCallback onAudioSelect; - final PlaybackConfig config; - final GlobalKey videoKey; - final LibraryItem? meta; - final Future Function(int index) onVideoChange; - - const VideoViewerMobile({ - super.key, - required this.onLibrarySelect, - required this.onSubtitleSelect, - required this.player, - required this.source, - required this.controller, - required this.onAudioSelect, - required this.config, - required this.videoKey, - required this.meta, - required this.onVideoChange, - }); - - @override - State createState() => _VideoViewerMobileState(); -} - -class _VideoViewerMobileState extends State { - final Logger _logger = Logger('_VideoViewerMobileState'); - bool isScaled = false; - - @override - build(BuildContext context) { - final mobile = _getMobileControls( - context, - onLibrarySelect: widget.onLibrarySelect, - player: widget.player, - source: widget.source, - onSubtitleClick: widget.onSubtitleSelect, - onAudioClick: widget.onAudioSelect, - toggleScale: () { - setState(() { - isScaled = !isScaled; - }); - }, - ); - - String subtitleStyleName = widget.config.subtitleStyle ?? 'Normal'; - String subtitleStyleColor = widget.config.subtitleColor ?? 'white'; - double subtitleSize = widget.config.subtitleSize; - - Color hexToColor(String hexColor) { - final hexCode = hexColor.replaceAll('#', ''); - try { - return Color(int.parse('0x$hexCode')); - } catch (e) { - return Colors.white; - } - } - - FontStyle getFontStyleFromString(String styleName) { - switch (styleName.toLowerCase()) { - case 'italic': - return FontStyle.italic; - case 'normal': - default: - return FontStyle.normal; - } - } - - FontStyle currentFontStyle = getFontStyleFromString(subtitleStyleName); - return MaterialVideoControlsTheme( - fullscreen: mobile, - normal: mobile, - child: Video( - subtitleViewConfiguration: SubtitleViewConfiguration( - style: TextStyle( - color: hexToColor(subtitleStyleColor), - fontSize: subtitleSize, - fontStyle: currentFontStyle, - fontWeight: FontWeight.bold, - ), - ), - fit: isScaled ? BoxFit.fitWidth : BoxFit.fitHeight, - pauseUponEnteringBackgroundMode: true, - key: widget.videoKey, - onExitFullscreen: () async { - await defaultExitNativeFullscreen(); - Navigator.of(context).pop(); - }, - controller: widget.controller, - controls: MaterialVideoControls, - ), - ); - } - - _getMobileControls( - BuildContext context, { - required DocSource source, - required Player player, - required VoidCallback onSubtitleClick, - required VoidCallback onAudioClick, - required VoidCallback toggleScale, - required VoidCallback onLibrarySelect, - }) { - final mediaQuery = MediaQuery.of(context); - final meta = widget.meta; - - return MaterialVideoControlsThemeData( - topButtonBar: [ - MaterialCustomButton( - onPressed: () { - Navigator.of( - context, - rootNavigator: true, - ).pop(); - Navigator.of( - context, - rootNavigator: true, - ).pop(); - }, - icon: const Icon( - Icons.arrow_back, - ), - ), - Text( - meta.toString(), - style: Theme.of(context).textTheme.bodyLarge, - ), - const Spacer(), - if (meta is types.Meta) - if (meta.type == "series") - SeasonSource( - meta: meta, - isMobile: true, - player: player, - onVideoChange: (index) async { - await widget.onVideoChange(index); - setState(() {}); - }, - ), - ], - bufferingIndicatorBuilder: (source is TorrentSource) - ? (ctx) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 10.0), - child: TorrentStats( - torrentHash: (source).infoHash, - ), - ); - } - : null, - brightnessGesture: true, - seekGesture: true, - seekOnDoubleTap: true, - gesturesEnabledWhileControlsVisible: true, - shiftSubtitlesOnControlsVisibilityChange: true, - seekBarMargin: const EdgeInsets.only(bottom: 54), - speedUpOnLongPress: true, - speedUpFactor: 2, - volumeGesture: true, - bottomButtonBar: [ - const MaterialPlayOrPauseButton(), - const MaterialPositionIndicator(), - const Spacer(), - MaterialCustomButton( - onPressed: () { - final speeds = [ - 0.5, - 0.75, - 1.0, - 1.25, - 1.5, - 1.75, - 2.0, - 2.25, - 2.5, - 3.0, - 3.25, - 3.5, - 3.75, - 4.0, - 4.25, - 4.5, - 4.75, - 5.0 - ]; - showCupertinoModalPopup( - context: context, - builder: (ctx) => Card( - child: Container( - height: MediaQuery.of(context).size.height * 0.4, - decoration: BoxDecoration( - color: Theme.of(context).dialogBackgroundColor, - borderRadius: - const BorderRadius.vertical(top: Radius.circular(12)), - ), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - 'Select Playback Speed', - style: Theme.of(context).textTheme.titleMedium, - ), - ), - Expanded( - child: ListView.builder( - itemCount: speeds.length, - itemBuilder: (context, index) { - final speed = speeds[index]; - return ListTile( - title: Text('${speed}x'), - selected: player.state.rate == speed, - onTap: () { - player.setRate(speed); - Navigator.pop(context); - }, - ); - }, - ), - ), - ], - ), - ), - ), - ); - }, - icon: const Icon(Icons.speed), - ), - MaterialCustomButton( - onPressed: () { - onSubtitleClick(); - }, - icon: const Icon(Icons.subtitles), - ), - MaterialCustomButton( - onPressed: () { - onAudioClick(); - }, - icon: const Icon(Icons.audio_file), - ), - MaterialCustomButton( - onPressed: () { - toggleScale(); - }, - icon: const Icon(Icons.fit_screen_outlined), - ), - ], - topButtonBarMargin: EdgeInsets.only( - top: mediaQuery.padding.top, - ), - bottomButtonBarMargin: EdgeInsets.only( - bottom: mediaQuery.viewInsets.bottom, - left: 4.0, - right: 4.0, - ), - ); - } -} diff --git a/lib/features/doc_viewer/container/video_viewer/video_viewer_ui.dart b/lib/features/doc_viewer/container/video_viewer/video_viewer_ui.dart deleted file mode 100644 index ff3ef80..0000000 --- a/lib/features/doc_viewer/container/video_viewer/video_viewer_ui.dart +++ /dev/null @@ -1,308 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:logging/logging.dart'; -import 'package:madari_client/features/connections/service/base_connection_service.dart'; -import 'package:madari_client/features/doc_viewer/container/video_viewer/audio_track_selector.dart'; -import 'package:madari_client/features/doc_viewer/container/video_viewer/subtitle_selector.dart'; -import 'package:madari_client/features/doc_viewer/container/video_viewer/tv_controls.dart'; -import 'package:madari_client/features/doc_viewer/container/video_viewer/video_viewer_mobile_ui.dart'; -import 'package:madari_client/features/doc_viewer/types/doc_source.dart'; -import 'package:madari_client/utils/load_language.dart'; -import 'package:media_kit/media_kit.dart'; -import 'package:media_kit_video/media_kit_video.dart'; - -import '../../../../utils/tv_detector.dart'; -import '../../../connections/types/stremio/stremio_base.types.dart' as types; -import '../../../connections/widget/base/render_stream_list.dart'; -import 'desktop_video_player.dart'; - -class VideoViewerUi extends StatefulWidget { - final VideoController controller; - final Player player; - final PlaybackConfig config; - final DocSource source; - final VoidCallback onLibrarySelect; - final BaseConnectionService? service; - final LibraryItem? meta; - final Function( - DocSource source, - LibraryItem item, - ) onSourceChange; - - const VideoViewerUi({ - super.key, - required this.controller, - required this.player, - required this.config, - required this.source, - required this.onLibrarySelect, - required this.service, - this.meta, - required this.onSourceChange, - }); - - @override - State createState() => _VideoViewerUiState(); -} - -class _VideoViewerUiState extends State { - late final GlobalKey key = GlobalKey(); - final Logger _logger = Logger('_VideoViewerUiState'); - - final List listeners = []; - - bool defaultConfigSelected = false; - - bool subtitleSelectionHandled = false; - bool audioSelectionHandled = false; - - void setDefaultAudioTracks(Tracks tracks) { - if (defaultConfigSelected == true && - (tracks.audio.length <= 1 || tracks.audio.length <= 1)) { - return; - } - - defaultConfigSelected = true; - - widget.controller.player.setRate(widget.config.playbackSpeed); - - final defaultSubtitle = widget.config.defaultSubtitleTrack; - final defaultAudio = widget.config.defaultAudioTrack; - - for (final item in tracks.audio) { - if ((defaultAudio == item.id || - defaultAudio == item.language || - defaultAudio == item.title) && - audioSelectionHandled == false) { - widget.controller.player.setAudioTrack(item); - audioSelectionHandled = true; - break; - } - } - - if (widget.config.disableSubtitle) { - for (final item in tracks.subtitle) { - if ((item.id == "no" || item.language == "no" || item.title == "no") && - subtitleSelectionHandled == false) { - widget.controller.player.setSubtitleTrack(item); - subtitleSelectionHandled = true; - } - } - } else { - for (final item in tracks.subtitle) { - if ((defaultSubtitle == item.id || - defaultSubtitle == item.language || - defaultSubtitle == item.title) && - subtitleSelectionHandled == false) { - subtitleSelectionHandled = true; - widget.controller.player.setSubtitleTrack(item); - break; - } - } - } - } - - @override - void initState() { - super.initState(); - - final listenerComplete = widget.player.stream.completed.listen((completed) { - if (completed) { - widget.onLibrarySelect(); - key.currentState?.exitFullscreen(); - } - }); - - listeners.add(listenerComplete); - - if (!kIsWeb) { - if (Platform.isAndroid || Platform.isIOS) { - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - key.currentState?.enterFullscreen(); - }); - } - } - - final listener = widget.player.stream.tracks.listen((tracks) { - if (mounted) { - setDefaultAudioTracks(tracks); - } - }); - - listeners.add(listener); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - - print(widget.meta.toString()); - } - - @override - void dispose() { - super.dispose(); - - for (final listener in listeners) { - listener.cancel(); - } - } - - @override - Widget build(BuildContext context) { - return _buildBody(context); - } - - _buildBody(BuildContext context) { - if (DeviceDetector.isTV()) { - return MaterialTvVideoControlsTheme( - fullscreen: const MaterialTvVideoControlsThemeData(), - normal: const MaterialTvVideoControlsThemeData(), - child: Video( - width: MediaQuery.of(context).size.width, - fit: BoxFit.fitWidth, - controller: widget.controller, - controls: MaterialTvVideoControls, - ), - ); - } - - switch (Theme.of(context).platform) { - case TargetPlatform.android: - case TargetPlatform.iOS: - return VideoViewerMobile( - onLibrarySelect: widget.onLibrarySelect, - onSubtitleSelect: onSubtitleSelect, - player: widget.player, - source: widget.source, - controller: widget.controller, - onAudioSelect: onAudioSelect, - config: widget.config, - videoKey: key, - meta: widget.meta, - onVideoChange: (index) async { - Navigator.of(context).pop(); - - widget.player.pause(); - - final result = await showModalBottomSheet( - context: context, - isScrollControlled: true, - constraints: BoxConstraints( - maxHeight: MediaQuery.of(context).size.height, - ), - builder: (context) { - return Scaffold( - appBar: AppBar(), - body: RenderStreamList( - service: widget.service!, - id: (widget.meta as types.Meta).copyWith( - selectedVideoIndex: index, - ), - shouldPop: true, - ), - ); - }, - ); - - if (result != null) { - widget.onSourceChange( - result, - (widget.meta as types.Meta).copyWith( - selectedVideoIndex: index, - ), - ); - } - }, - ); - default: - return _buildDesktop(context); - } - } - - _buildDesktop(BuildContext context) { - final desktop = getDesktopControls( - context, - player: widget.player, - source: widget.source, - onAudioSelect: onAudioSelect, - onSubtitleSelect: onSubtitleSelect, - meta: widget.meta, - onVideoChange: (index) async { - Navigator.of(context).pop(); - - widget.player.pause(); - - final result = await showModalBottomSheet( - context: context, - isScrollControlled: true, - constraints: BoxConstraints( - maxHeight: MediaQuery.of(context).size.height, - ), - builder: (context) { - return Scaffold( - appBar: AppBar(), - body: RenderStreamList( - service: widget.service!, - id: (widget.meta as types.Meta).copyWith( - selectedVideoIndex: index, - ), - shouldPop: true, - ), - ); - }, - ); - - if (result != null) { - widget.onSourceChange( - result, - (widget.meta as types.Meta).copyWith( - selectedVideoIndex: index, - ), - ); - } - }, - ); - - return MaterialDesktopVideoControlsTheme( - normal: desktop, - fullscreen: desktop, - child: Video( - key: key, - width: MediaQuery.of(context).size.width, - fit: BoxFit.fitWidth, - controller: widget.controller, - controls: MaterialDesktopVideoControls, - ), - ); - } - - onAudioSelect() { - _logger.info('Audio track selection triggered.'); - - showCupertinoModalPopup( - context: context, - builder: (ctx) => AudioTrackSelector( - player: widget.player, - config: widget.config, - ), - ); - } - - onSubtitleSelect() { - _logger.info('Subtitle selection triggered.'); - - showCupertinoModalPopup( - context: context, - builder: (ctx) => SubtitleSelector( - player: widget.player, - config: widget.config, - service: widget.service, - meta: widget.meta, - ), - ); - } -} diff --git a/lib/features/doc_viewer/types/doc_source.dart b/lib/features/doc_viewer/types/doc_source.dart deleted file mode 100644 index a803e09..0000000 --- a/lib/features/doc_viewer/types/doc_source.dart +++ /dev/null @@ -1,365 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'dart:math'; - -import 'package:flutter/foundation.dart'; -import 'package:http/http.dart' as http; -import 'package:json_annotation/json_annotation.dart'; -import 'package:path/path.dart' as path; - -import '../utils/get_types.dart'; - -part 'doc_source.g.dart'; - -enum DocType { pdf, video, audio, photo, unknown } - -sealed class DocSource { - String title; - String id; - String? season; - String? episode; - - DocSource({ - required this.title, - required this.id, - this.season, - this.episode, - }); - - DocType getType(); - - Future init() async {} - - void dispose(); -} - -class IframeSource extends DocSource { - late final String url; - - IframeSource({ - required this.url, - required super.title, - required super.id, - super.season, - super.episode, - }); - - @override - void dispose() {} - - @override - DocType getType() { - throw UnimplementedError(); - } -} - -class ProgressStatus extends DocSource { - final double? percentage; - final String? progressText; - - @override - DocType getType() { - return DocType.unknown; - } - - ProgressStatus({ - required super.id, - required super.title, - this.progressText, - this.percentage, - }); - - @override - void dispose() {} -} - -class URLSource extends DocSource { - String url; - String? fileName; - Map headers = {}; - - URLSource({ - required super.title, - required this.url, - required super.id, - super.season, - super.episode, - this.fileName, - this.headers = const {}, - }); - - @override - DocType getType() { - String cleanUrl = (url).split('?').first; - String extension = (fileName ?? cleanUrl).split('.').last.toLowerCase(); - - return getTypeFromExtension(extension.trim()); - } - - @override - void dispose() {} -} - -class MediaURLSource extends URLSource { - MediaURLSource({required super.title, required super.url, required super.id}); - - @override - DocType getType() { - return DocType.video; - } -} - -class TorrentSource extends URLSource { - final String infoHash; - @override - final String fileName; - final List? trackers; - bool disposed = false; - - TorrentSource({ - required super.id, - required super.title, - required this.infoHash, - required this.fileName, - super.season, - super.episode, - this.trackers, - super.url = "", - }); - - @override - DocType getType() { - String extension = fileName.split('.').last.toLowerCase(); - - return getTypeFromExtension(extension); - } - - @override - Future init() async { - final trackers = [ - "udp://47.ip-51-68-199.eu:6969/announce", - "udp://9.rarbg.me:2940", - "udp://9.rarbg.to:2820", - "udp://exodus.desync.com:6969/announce", - "udp://explodie.org:6969/announce", - "udp://ipv4.tracker.harry.lu:80/announce", - "udp://open.stealth.si:80/announce", - "udp://opentor.org:2710/announce", - "udp://opentracker.i2p.rocks:6969/announce", - "udp://retracker.lanta-net.ru:2710/announce", - "udp://tracker.cyberia.is:6969/announce", - "udp://tracker.dler.org:6969/announce", - "udp://tracker.ds.is:6969/announce", - "udp://tracker.internetwarriors.net:1337", - "udp://tracker.openbittorrent.com:6969/announce", - "udp://tracker.opentrackr.org:1337/announce", - "udp://tracker.tiny-vps.com:6969/announce", - "udp://tracker.torrent.eu.org:451/announce", - "udp://valakas.rollo.dnsabr.com:2710/announce", - "udp://www.torrent.eu.org:451/announce" - ]; - - final value1 = - await http.get(Uri.parse("http://localhost:64544/torrents/$infoHash")); - - if (jsonDecode(value1.body)["error_kind"] == "torrent_not_found") { - await http.post( - Uri.parse("http://localhost:64544/torrents?overwrite=true"), - body: addTrackersToMagnet( - "magnet:?xt=urn:btih:${Uri.encodeComponent(infoHash)}", - trackers, - ), - ); - } else { - await http.post( - Uri.parse("http://localhost:64544/torrents/$infoHash/start"), - ); - } - - final value = await http.get( - Uri.parse("http://localhost:64544/torrents/$infoHash"), - ); - - final obj = jsonDecode( - value.body, - ); - - final objTorrent = TorrentInfoObject.fromJson(obj); - - for (final (index, file) in objTorrent.files.indexed) { - if (path.basename(file.name) == fileName) { - url = "http://localhost:64544/torrents/$infoHash/stream/$index"; - - await http.post( - Uri.parse( - "http://localhost:64544/torrents/$infoHash/update_only_files"), - headers: { - "Content-Type": "application/json", - }, - body: jsonEncode( - { - "only_files": [index] - }, - ), - ); - break; - } - } - - if (url == "") throw AssertionError(); - - return super.init(); - } - - @override - void dispose() { - super.dispose(); - - disposed = true; - - http - .post( - Uri.parse( - "http://localhost:64544/torrents/$infoHash/pause", - ), - ) - .then( - (docs) { - if (kDebugMode) { - print(docs.statusCode); - print("Stopped downloading file"); - } - }, - ); - } - - Future readFirst1MBFromUrl(String url) async { - final client = http.Client(); - - try { - int attempts = 0; - const maxAttempts = 10; - - while (attempts < maxAttempts) { - if (kDebugMode) { - print("Reading $attempts at $url"); - } - - if (disposed) { - break; - } - - try { - final request = http.Request('GET', Uri.parse(url)); - request.headers['range'] = 'bytes=0-${1024 * 1}'; - - final streamedResponse = await client.send(request); - - // Check if the response is successful - if (streamedResponse.statusCode >= 200 && - streamedResponse.statusCode < 300) { - final bytes = - await streamedResponse.stream.take(1024 * 1024).fold>( - [], - (previous, element) => previous..addAll(element), - ); - return Uint8List.fromList(bytes); - } - - throw HttpException( - 'Failed with status: ${streamedResponse.statusCode}'); - } catch (e) { - attempts++; - if (attempts >= maxAttempts) { - throw Exception('Failed after $maxAttempts attempts: $e'); - } - - if (kDebugMode) { - print(e); - } - - await Future.delayed( - Duration(milliseconds: pow(2, attempts).toInt() * 100), - ); - } - } - throw Exception('Unexpected error'); - } finally { - client.close(); - } - } -} - -@JsonSerializable() -class TorrentInfoObject { - final List files; - - TorrentInfoObject({ - required this.files, - }); - - factory TorrentInfoObject.fromJson(Map json) => - _$TorrentInfoObjectFromJson(json); - - Map toJson() => _$TorrentInfoObjectToJson(this); -} - -@JsonSerializable() -class TorrentFile { - final String name; - - TorrentFile({ - required this.name, - }); - - factory TorrentFile.fromJson(Map json) => - _$TorrentFileFromJson(json); - - Map toJson() => _$TorrentFileToJson(this); -} - -String escapeRegex(String input) { - const specialChars = r'[.*+?^${}()|[\]\\]'; - - return input.replaceAllMapped( - RegExp(specialChars), (Match match) => '\\${match.group(0)}'); -} - -class FileSource extends DocSource { - String filePath; - - FileSource({ - required super.title, - required this.filePath, - required super.id, - }); - - @override - DocType getType() { - String extension = filePath.split('.').last.toLowerCase(); - return getTypeFromExtension(extension); - } - - @override - void dispose() {} -} - -String addTrackersToMagnet(String magnetLink, List trackers) { - final uri = Uri.parse(magnetLink); - - if (!uri.scheme.contains("magnet")) { - throw ArgumentError("Invalid magnet link"); - } - - final existingTrackers = uri.queryParametersAll['tr'] ?? []; - final updatedTrackers = [...existingTrackers, ...trackers]; - - final updatedQueryParameters = - Map>.from(uri.queryParametersAll) - ..['tr'] = updatedTrackers; - - final updatedUri = uri.replace(queryParameters: updatedQueryParameters); - - return updatedUri.toString(); -} diff --git a/lib/features/doc_viewer/utils/get_types.dart b/lib/features/doc_viewer/utils/get_types.dart deleted file mode 100644 index 74c278a..0000000 --- a/lib/features/doc_viewer/utils/get_types.dart +++ /dev/null @@ -1,50 +0,0 @@ -import '../types/doc_source.dart'; - -DocType getTypeFromExtension(String extension) { - switch (extension) { - // PDF extensions - case 'pdf': - return DocType.pdf; - - // Video extensions - case 'mp4': - case 'avi': - case 'mov': - case 'wmv': - case 'mkv': - case 'webm': - case 'flv': - case 'm4v': - case 'mpg': - case 'mpeg': - case '3gp': - return DocType.video; - - // Audio extensions - case 'mp3': - case 'wav': - case 'flac': - case 'aac': - case 'm4a': - case 'wma': - case 'ogg': - case 'opus': - return DocType.audio; - - // Photo extensions - case 'jpg': - case 'jpeg': - case 'png': - case 'gif': - case 'bmp': - case 'tiff': - case 'webp': - case 'svg': - case 'heic': - case 'raw': - return DocType.photo; - - default: - return DocType.unknown; - } -} diff --git a/lib/features/downloads/container/index.dart b/lib/features/downloads/container/index.dart deleted file mode 100644 index 81f3544..0000000 --- a/lib/features/downloads/container/index.dart +++ /dev/null @@ -1,324 +0,0 @@ -import 'package:background_downloader/background_downloader.dart'; -import 'package:flutter/material.dart'; -import 'package:http/http.dart' as http; -import 'package:path/path.dart' as path; -import 'package:url_launcher/url_launcher.dart'; - -class DownloadDialog extends StatefulWidget { - const DownloadDialog({super.key}); - - @override - State createState() => _DownloadDialogState(); -} - -class _DownloadDialogState extends State { - final _formKey = GlobalKey(); - final _urlController = TextEditingController(); - final _nameController = TextEditingController(); - bool _isValidating = false; - String? _validationError; - Map? _fileInfo; - - Future _startDownload() async { - if (!_formKey.currentState!.validate()) return; - - final task = DownloadTask( - url: _urlController.text, - filename: '${_nameController.text}.mp4', - directory: 'downloads', - updates: Updates.statusAndProgress, - allowPause: true, - displayName: _nameController.text, - ); - - await FileDownloader().enqueue(task); - if (mounted) Navigator.of(context).pop(); - } - - Future _validateUrl() async { - if (_urlController.text.isEmpty) return; - - setState(() { - _isValidating = true; - _validationError = null; - _fileInfo = null; - }); - - try { - final uri = Uri.parse(_urlController.text); - if (!await canLaunchUrl(uri)) { - throw Exception('Invalid URL'); - } - - // Make a HEAD request to get file information - final response = await http.head(uri); - - if (response.statusCode != 200) { - throw Exception('Could not access file'); - } - - // Get file size from headers - final contentLength = response.headers['content-length']; - final fileSize = contentLength != null - ? _formatFileSize(int.parse(contentLength)) - : 'Unknown size'; - - // Get content type from headers - final contentType = response.headers['content-type'] ?? 'Unknown type'; - - // Extract filename from URL or Content-Disposition header - String fileName = ''; - final disposition = response.headers['content-disposition']; - if (disposition != null && disposition.contains('filename=')) { - fileName = disposition.split('filename=')[1].replaceAll('"', ''); - } else { - fileName = path.basename(uri.path); - } - - // Remove extension from filename for display name - _nameController.text = path.basenameWithoutExtension(fileName); - - _fileInfo = { - 'size': fileSize, - 'type': contentType, - 'filename': fileName, - }; - } catch (e) { - _validationError = 'Invalid URL: ${e.toString()}'; - } finally { - setState(() { - _isValidating = false; - }); - } - } - - String _formatFileSize(int bytes) { - if (bytes < 1024) return '$bytes B'; - if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; - if (bytes < 1024 * 1024 * 1024) { - return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; - } - return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB'; - } - - @override - Widget build(BuildContext context) { - return Dialog( - backgroundColor: Colors.grey[900], - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - child: Container( - width: MediaQuery.of(context).size.width < 600 ? double.infinity : 500, - padding: const EdgeInsets.all(24), - child: Form( - key: _formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header - const Row( - children: [ - Icon( - Icons.download_rounded, - size: 30, - ), - SizedBox(width: 12), - Text( - 'Add New Download', - style: TextStyle( - color: Colors.white, - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - const SizedBox(height: 24), - - // URL Input Field - TextFormField( - controller: _urlController, - style: const TextStyle(color: Colors.white), - decoration: InputDecoration( - labelText: 'URL', - hintText: 'Paste your download URL here', - filled: true, - prefixIcon: const Icon(Icons.link, color: Colors.grey), - suffixIcon: _isValidating - ? const Padding( - padding: EdgeInsets.all(12), - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: - AlwaysStoppedAnimation(Colors.red), - ), - ) - : IconButton( - icon: const Icon(Icons.check_circle_outline, - color: Colors.white), - onPressed: _validateUrl, - ), - ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter a URL'; - } - return null; - }, - keyboardType: TextInputType.url, - onChanged: (_) => setState(() => _fileInfo = null), - ), - - // Validation Error - if (_validationError != null) ...[ - const SizedBox(height: 8), - Row( - children: [ - const Icon( - Icons.error_outline, - color: Colors.red, - size: 16, - ), - const SizedBox(width: 8), - Text( - _validationError!, - style: const TextStyle( - color: Colors.red, - fontSize: 12, - ), - ), - ], - ), - ], - - // File Information - if (_fileInfo != null) ...[ - const SizedBox(height: 16), - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.grey[850], - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.grey[700]!), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _infoRow(Icons.folder_outlined, 'File Size:', - _fileInfo?['size'] ?? ''), - const SizedBox(height: 12), - _infoRow(Icons.description_outlined, 'Type:', - _fileInfo?['type'] ?? ''), - const SizedBox(height: 12), - _infoRow(Icons.insert_drive_file_outlined, 'File:', - _fileInfo?['filename'] ?? ''), - ], - ), - ), - - // Display Name Input - const SizedBox(height: 16), - TextFormField( - controller: _nameController, - style: const TextStyle(color: Colors.white), - decoration: const InputDecoration( - labelText: 'Display Name', - hintText: 'Enter a name for this download', - filled: true, - prefixIcon: Icon(Icons.edit, color: Colors.grey), - ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter a name'; - } - return null; - }, - ), - ], - - // Action Buttons - const SizedBox(height: 24), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 16, vertical: 12), - ), - child: Text( - 'Cancel', - style: TextStyle(color: Colors.grey[400]), - ), - ), - const SizedBox(width: 16), - ElevatedButton( - onPressed: _fileInfo != null ? _startDownload : null, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric( - horizontal: 24, - vertical: 12, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - disabledBackgroundColor: Colors.grey[700], - ), - child: const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.download_rounded, size: 20), - SizedBox(width: 8), - Text( - 'Download', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ], - ), - ], - ), - ), - ), - ); - } - - Widget _infoRow(IconData icon, String label, String value) { - return Row( - children: [ - Icon(icon, color: Colors.grey[400], size: 20), - const SizedBox(width: 8), - Text( - label, - style: TextStyle(color: Colors.grey[400]), - ), - const SizedBox(width: 8), - Expanded( - child: Text( - value, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w500, - ), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ); - } - - @override - void dispose() { - _urlController.dispose(); - _nameController.dispose(); - super.dispose(); - } -} diff --git a/lib/features/downloads/pages/downloads_page.dart b/lib/features/downloads/pages/downloads_page.dart new file mode 100644 index 0000000..8c3b8b8 --- /dev/null +++ b/lib/features/downloads/pages/downloads_page.dart @@ -0,0 +1,192 @@ +import 'package:background_downloader/background_downloader.dart'; +import 'package:flutter/material.dart'; + +import '../service/download_service.dart'; + +class DownloadsPage extends StatefulWidget { + const DownloadsPage({super.key}); + + @override + State createState() => _DownloadsPageState(); +} + +class _DownloadsPageState extends State { + final _urlController = TextEditingController(); + final _filenameController = TextEditingController(); + final _formKey = GlobalKey(); + bool _isSubmitting = false; + + @override + void dispose() { + _urlController.dispose(); + _filenameController.dispose(); + super.dispose(); + } + + Future _startDownload() async { + if (!_formKey.currentState!.validate()) return; + + setState(() => _isSubmitting = true); + + try { + await DownloadService.instance.startDownload( + _urlController.text, + _filenameController.text, + ); + + if (mounted) { + _urlController.clear(); + _filenameController.clear(); + } + } finally { + if (mounted) { + setState(() => _isSubmitting = false); + } + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar( + title: const Text("Downloads"), + ), + body: Center( + child: const Text("Not implemented") ?? + SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Form( + key: _formKey, + child: Column( + children: [ + TextFormField( + controller: _urlController, + decoration: const InputDecoration( + labelText: 'URL', + border: OutlineInputBorder(), + ), + validator: (value) { + if (value?.isEmpty ?? true) + return 'URL is required'; + if (!(Uri.tryParse(value!)?.hasAbsolutePath ?? + true)) { + return 'Invalid URL'; + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _filenameController, + decoration: const InputDecoration( + labelText: 'Filename', + border: OutlineInputBorder(), + ), + validator: (value) { + if (value?.isEmpty ?? true) + return 'Filename is required'; + return null; + }, + ), + const SizedBox(height: 16), + SizedBox( + height: 48, + child: FilledButton( + onPressed: _isSubmitting ? null : _startDownload, + child: _isSubmitting + ? const SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator(), + ) + : const Text('Start Download'), + ), + ), + ], + ), + ), + const SizedBox(height: 32), + StreamBuilder>( + stream: DownloadService.instance.records, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const Center(child: CircularProgressIndicator()); + } + + final records = snapshot.data!; + if (records.isEmpty) { + return Center( + child: Text( + 'No downloads yet', + style: theme.textTheme.bodyLarge, + ), + ); + } + + return ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: records.length, + itemBuilder: (context, index) { + final record = records[index]; + final progress = + (record.progress * 100).toStringAsFixed(1); + + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + title: Text(record.task.filename), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (record.status == TaskStatus.running) + LinearProgressIndicator( + value: record.progress), + const SizedBox(height: 4), + Text('Status: ${record.status.name}'), + if (record.status == TaskStatus.running) + Text('Progress: $progress%'), + ], + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (record.status == TaskStatus.running) ...[ + IconButton( + icon: const Icon(Icons.pause), + onPressed: () => DownloadService.instance + .pauseDownload(record.taskId), + ), + ] else if (record.status == + TaskStatus.paused) ...[ + IconButton( + icon: const Icon(Icons.play_arrow), + onPressed: () => DownloadService.instance + .resumeDownload(record.taskId), + ), + ], + IconButton( + icon: const Icon(Icons.close), + onPressed: () => DownloadService.instance + .cancelDownload(record.taskId), + ), + ], + ), + ), + ); + }, + ); + }, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/downloads/service/download_service.dart b/lib/features/downloads/service/download_service.dart new file mode 100644 index 0000000..313fad3 --- /dev/null +++ b/lib/features/downloads/service/download_service.dart @@ -0,0 +1,82 @@ +import 'dart:async'; + +import 'package:background_downloader/background_downloader.dart'; + +class DownloadService { + static final DownloadService _instance = DownloadService._internal(); + static DownloadService get instance => _instance; + + final StreamController> _recordsController = + StreamController>.broadcast(); + Stream> get records => _recordsController.stream; + + final Map _tasks = {}; + + DownloadService._internal() { + _init(); + } + + Future _init() async { + await FileDownloader().trackTasks(); + + FileDownloader().configureNotification( + running: const TaskNotification('Downloading', 'File: {filename}'), + complete: const TaskNotification('Download Complete', 'File: {filename}'), + error: const TaskNotification('Download Failed', 'File: {filename}'), + progressBar: true, + ); + + FileDownloader().updates.listen((update) async { + await _updateRecords(); + }); + } + + Future _updateRecords() async { + final records = await FileDownloader().database.allRecords(); + _recordsController.add(records); + } + + Future startDownload(String url, String filename) async { + final task = DownloadTask( + url: url, + filename: filename, + updates: Updates.statusAndProgress, + allowPause: true, + retries: 3, + ); + + _tasks[task.taskId] = task; + final success = await FileDownloader().enqueue(task); + await _updateRecords(); + return success; + } + + Future pauseDownload(String taskId) async { + final task = _tasks[taskId]; + if (task != null) { + await FileDownloader().pause(task); + await _updateRecords(); + } + } + + Future resumeDownload(String taskId) async { + final task = _tasks[taskId]; + if (task != null) { + await FileDownloader().resume(task); + await _updateRecords(); + } + } + + Future cancelDownload(String taskId) async { + final task = _tasks[taskId]; + if (task != null) { + await FileDownloader().cancelTaskWithId(taskId); + _tasks.remove(taskId); + await _updateRecords(); + } + } + + Future dispose() async { + await _recordsController.close(); + } +} diff --git a/lib/features/downloads/service/service.dart b/lib/features/downloads/service/service.dart deleted file mode 100644 index f155e4b..0000000 --- a/lib/features/downloads/service/service.dart +++ /dev/null @@ -1,72 +0,0 @@ -import 'dart:async'; - -import 'package:background_downloader/background_downloader.dart'; -import 'package:flutter/material.dart'; - -class DownloadService { - static final DownloadService _instance = DownloadService._internal(); - static DownloadService get instance => _instance; - - final FileDownloader _downloader = FileDownloader(); - final _updateController = StreamController.broadcast(); - - Stream get updates => _updateController.stream; - StreamSubscription? _downloadSubscription; - - DownloadService._internal(); - - Future initialize() async { - await _downloader.trackTasks(); - - // Subscribe to FileDownloader updates and broadcast them - _downloadSubscription = _downloader.updates.listen( - (update) => _updateController.add(update), - onError: (error) => _updateController.addError(error), - ); - - FileDownloader().configureNotification( - running: const TaskNotification('Downloading', 'File: {filename}'), - complete: const TaskNotification('Download finished', 'File: {filename}'), - progressBar: true, - ); - } - - void dispose() { - _downloadSubscription?.cancel(); - _updateController.close(); - } - - Future> getAllDownloads() async { - return await _downloader.database.allRecords(); - } - - Future getById(String taskId) async { - return await _downloader.database.recordForId(taskId); - } - - Future pauseDownload(DownloadTask task) async { - await _downloader.pause(task); - } - - Future resumeDownload(DownloadTask task) async { - await _downloader.resume(task); - } - - Future deleteDownload(String taskId) async { - await _downloader.database.deleteRecordWithId(taskId); - } - - Future startDownload(DownloadTask task) async { - const permissionType = PermissionType.notifications; - var status = await FileDownloader().permissions.status(permissionType); - if (status != PermissionStatus.granted) { - if (await FileDownloader() - .permissions - .shouldShowRationale(permissionType)) {} - status = await FileDownloader().permissions.request(permissionType); - debugPrint('Permission for $permissionType was $status'); - } - - await _downloader.enqueue(task); - } -} diff --git a/lib/features/explore/containers/explore_addon.dart b/lib/features/explore/containers/explore_addon.dart new file mode 100644 index 0000000..07e07a3 --- /dev/null +++ b/lib/features/explore/containers/explore_addon.dart @@ -0,0 +1,348 @@ +import 'package:cached_query/cached_query.dart'; +import 'package:flex_color_picker/flex_color_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; +import 'package:madari_client/features/streamio_addons/extension/query_extension.dart'; +import 'package:madari_client/features/streamio_addons/models/stremio_base_types.dart'; +import 'package:madari_client/features/streamio_addons/service/stremio_addon_service.dart'; +import 'package:madari_client/utils/array-extension.dart'; + +import '../../widgetter/plugins/stremio/widgets/catalog_grid_full.dart'; +import '../../widgetter/plugins/stremio/widgets/error_card.dart'; + +final _logger = Logger('ExploreAddon'); + +class ExploreAddon extends StatefulWidget { + final List data; + const ExploreAddon({ + super.key, + required this.data, + }); + + @override + State createState() => _ExploreAddonState(); +} + +class _ExploreAddonState extends State { + String? selectedType; + String? selectedId; + String? selectedGenre; + StremioManifest? selectedAddon; + static const int pageSize = 50; + final service = StremioAddonService.instance; + + InfiniteQuery, int>? _query; + + @override + void initState() { + super.initState(); + + setFirstThing(); + setOptionValues(); + setQuery(); + } + + setQuery() { + _query = buildQuery(); + + setState(() {}); + } + + String get queryKey { + return "explorer_page_${selectedType}_${selectedId}_$selectedGenre"; + } + + InfiniteQuery, int> buildQuery() { + return InfiniteQuery( + key: queryKey, + config: QueryConfig( + cacheDuration: const Duration(days: 30), + refetchDuration: const Duration(hours: 8), + ), + getNextArg: (state) { + final lastPage = state.lastPage; + if (lastPage == null) return 1; + if (lastPage.length < pageSize) return null; + return state.length + 1; + }, + queryFn: (page) async { + _logger.info('Fetching catalog for page: $page'); + try { + final addonManifest = await service + .validateManifest(selectedAddon!.manifestUrl!) + .queryFn(); + + List items = []; + + if (selectedGenre != null) { + items.add( + ConnectionFilterItem( + title: "genre", + value: selectedGenre, + ), + ); + } + + return service.getCatalog( + addonManifest, + selectedType!, + selectedId!, + page - 1, + items, + ); + } catch (e, stack) { + _logger.severe('Error fetching catalog: $e', e, stack); + throw Exception('Failed to fetch catalog'); + } + }, + ); + } + + setFirstThing() { + final Set genres = {}; + + StremioManifest? selectedAddon; + + for (final item in widget.data) { + for (final value in item.catalogs!) { + selectedType ??= value.type; + + selectedAddon ??= item; + + if (selectedType == value.type) { + selectedId ??= value.id; + selectedAddon = item; + } + + if (selectedType == value.type && selectedId == value.id) { + final extra = value.extra?.firstWhereOrNull((extra) { + return extra.name == "genre"; + }); + + if (extra != null && extra.options?.isNotEmpty == true) { + for (final option in extra.options!) { + selectedGenre ??= option; + + selectedAddon = item; + + genres.add(option); + } + } + } + } + } + + this.selectedAddon = selectedAddon; + + this.genres = genres.toList(); + } + + setOptionValues() { + final Set types = {}; + + for (final item in widget.data) { + for (final value in item.catalogs!) { + if (value.type != selectedType) { + continue; + } + + types.add(value.id); + } + } + + categories = types.toList(); + } + + List get types { + final Set allTypes = {}; + + for (final item in widget.data) { + if (item.catalogs == null) { + continue; + } + for (final value in item.catalogs!) { + allTypes.add(value.type); + } + } + + return allTypes.toList(); + } + + List categories = []; + List genres = []; + + void _showSelectionSheet( + List items, + String title, + String current, + Function(String) onSelect, { + List resetTypes = const [], + }) { + showModalBottomSheet( + context: context, + builder: (context) => Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Text(title, style: Theme.of(context).textTheme.titleLarge), + ), + Expanded( + child: ListView.builder( + shrinkWrap: true, + itemCount: items.length, + itemBuilder: (context, index) => ListTile( + title: Text( + items[index] + .replaceAll(".", " ") + .split(" ") + .map((item) => item.capitalize) + .join(" "), + ), + selected: items[index] == current, + trailing: items[index] == current + ? Icon( + Icons.check_circle, + color: Theme.of(context).highlightColor, + ) + : null, + onTap: () { + onSelect(items[index]); + + if (resetTypes.contains('categories')) { + selectedId = null; + } + + if (resetTypes.contains('genres')) { + selectedGenre = null; + } + setFirstThing(); + setOptionValues(); + + setQuery(); + + Navigator.pop(context); + }, + ), + ), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + if (selectedId == null || selectedType == null) { + return const Scaffold( + body: ErrorCard(error: "No addon with support for catalog"), + ); + } + + return Scaffold( + body: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + FilterChip( + selected: true, + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + selectedType! + .replaceAll(".", " ") + .split(" ") + .map((item) => item.capitalize) + .join(" "), + ), + const Icon(Icons.arrow_drop_down, size: 18) + ], + ), + onSelected: (_) => _showSelectionSheet( + types, + 'Select Type', + selectedType!, + (value) => setState(() => selectedType = value), + resetTypes: [ + 'categories', + 'genres', + ], + ), + ), + const SizedBox(width: 8), + FilterChip( + selected: true, + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + selectedId! + .replaceAll(".", " ") + .split(" ") + .map((item) => item.capitalize) + .join(" "), + ), + const Icon(Icons.arrow_drop_down, size: 18) + ], + ), + onSelected: (_) => _showSelectionSheet( + categories, + 'Select Category', + selectedId!, + (value) => setState(() => selectedId = value), + resetTypes: [ + 'genres', + ], + ), + ), + const SizedBox(width: 8), + if (genres.isNotEmpty) + FilterChip( + selected: selectedGenre != null, + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + (selectedGenre ?? 'Genre') + .replaceAll(".", " ") + .split(" ") + .map((item) => item.capitalize) + .join(" "), + ), + const Icon(Icons.arrow_drop_down, size: 18) + ], + ), + onSelected: (_) => _showSelectionSheet( + genres, + 'Select Genre', + selectedGenre ?? '', + (value) => setState(() => selectedGenre = value), + ), + ), + ], + ), + ), + Expanded( + child: _query != null + ? CatalogFullView( + initialItems: const [], + prefix: "explore", + query: buildQuery(), + key: ValueKey(queryKey), + ) + : const Center( + child: CircularProgressIndicator(), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/explore/pages/explore.page.dart b/lib/features/explore/pages/explore.page.dart new file mode 100644 index 0000000..d11cc1e --- /dev/null +++ b/lib/features/explore/pages/explore.page.dart @@ -0,0 +1,74 @@ +import 'package:cached_query_flutter/cached_query_flutter.dart'; +import 'package:flutter/material.dart'; +import 'package:madari_client/features/streamio_addons/extension/query_extension.dart'; +import 'package:madari_client/features/streamio_addons/service/stremio_addon_service.dart'; +import 'package:madari_client/features/widgetter/plugins/stremio/widgets/error_card.dart'; + +import '../../streamio_addons/models/stremio_base_types.dart'; +import '../containers/explore_addon.dart'; + +class ExplorePage extends StatefulWidget { + const ExplorePage({ + super.key, + }); + + @override + State createState() => _ExplorePageState(); +} + +class _ExplorePageState extends State { + late Query> _query; + + @override + void initState() { + super.initState(); + + setQuery(); + } + + void setQuery() { + _query = Query( + key: "addons", + queryFn: () async { + final result = StremioAddonService.instance; + + return await result + .getInstalledAddons( + enabledOnly: true, + ) + .queryFn(); + }, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + leading: const Icon(Icons.explore_outlined), + title: const Text("Explore"), + ), + body: QueryBuilder( + builder: (context, state) { + if (state.status == QueryStatus.loading || state.data == null) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + if (state.data?.isEmpty == true) { + return const ErrorCard( + error: "No Addons found", + title: "No addons are configured", + ); + } + + return ExploreAddon( + data: state.data!, + ); + }, + query: _query, + ), + ); + } +} diff --git a/lib/features/files/container/file.container.dart b/lib/features/files/container/file.container.dart deleted file mode 100644 index 7104846..0000000 --- a/lib/features/files/container/file.container.dart +++ /dev/null @@ -1,169 +0,0 @@ -import 'package:flutter/material.dart'; - -class FileItem { - final String name; - final bool isDirectory; - final String? path; - - FileItem({ - required this.name, - required this.isDirectory, - this.path, - }); -} - -class FilesManagerContainer extends StatefulWidget { - final Future> Function(String? path) onLoadFiles; - final Future Function(String path, String name) onCreateFolder; - - const FilesManagerContainer({ - super.key, - required this.onLoadFiles, - required this.onCreateFolder, - }); - - @override - State createState() => _FilesManagerContainerState(); -} - -class _FilesManagerContainerState extends State { - late Future> _filesFuture; - String _currentPath = ''; - final List _navigationStack = []; - - @override - void initState() { - super.initState(); - _loadFiles(); - } - - void _loadFiles() { - _filesFuture = widget.onLoadFiles(_currentPath); - } - - void _navigateToFolder(String path) { - setState(() { - _navigationStack.add(_currentPath); - _currentPath = path; - _loadFiles(); - }); - } - - bool _navigateBack() { - if (_navigationStack.isNotEmpty) { - setState(() { - _currentPath = _navigationStack.removeLast(); - _loadFiles(); - }); - return true; - } - return false; - } - - Future _createFolder() async { - final controller = TextEditingController(); - final result = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Create New Folder'), - content: TextField( - controller: controller, - decoration: const InputDecoration( - hintText: 'Folder name', - ), - autofocus: true, - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.pop(context, true), - child: const Text('Create'), - ), - ], - ), - ); - - if (result == true && controller.text.isNotEmpty && mounted) { - try { - await widget.onCreateFolder(_currentPath, controller.text); - setState(() { - _loadFiles(); - }); - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Failed to create folder: $e')), - ); - } - } - } - } - - @override - Widget build(BuildContext context) { - return WillPopScope( - onWillPop: () async { - return !_navigateBack(); // Return true to exit app, false to stay - }, - child: Scaffold( - appBar: AppBar( - leading: _navigationStack.isNotEmpty - ? IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: _navigateBack, - ) - : null, - title: Text(_currentPath.isEmpty ? 'Files' : _currentPath), - actions: [ - IconButton( - icon: const Icon(Icons.create_new_folder), - onPressed: _createFolder, - ), - ], - ), - body: FutureBuilder>( - future: _filesFuture, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } - - if (snapshot.hasError) { - return Center( - child: Text('Error: ${snapshot.error}'), - ); - } - - final files = snapshot.data ?? []; - - if (files.isEmpty) { - return const Center( - child: Text('No files found'), - ); - } - - return ListView.builder( - itemCount: files.length, - itemBuilder: (context, index) { - final file = files[index]; - return ListTile( - leading: Icon( - file.isDirectory ? Icons.folder : Icons.insert_drive_file, - color: file.isDirectory ? Colors.amber : Colors.blue, - ), - title: Text(file.name), - onTap: file.isDirectory - ? () => _navigateToFolder(file.path!) - : null, - ); - }, - ); - }, - ), - ), - ); - } -} diff --git a/lib/features/getting_started/container/create_connection.dart b/lib/features/getting_started/container/create_connection.dart deleted file mode 100644 index d59e626..0000000 --- a/lib/features/getting_started/container/create_connection.dart +++ /dev/null @@ -1,526 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; -import 'package:http/http.dart' as http; -import 'package:madari_client/features/connection/services/stremio_service.dart'; -import 'package:madari_client/features/connection/types/stremio.dart'; -import 'package:pocketbase/pocketbase.dart'; - -import '../../../engine/engine.dart'; -import '../../settings/types/connection.dart'; - -class CreateConnectionStep extends StatefulWidget { - final void Function(Connection connection) onConnectionComplete; - - const CreateConnectionStep({ - super.key, - required this.onConnectionComplete, - }); - - @override - State createState() => _CreateConnectionStepState(); -} - -class _CreateConnectionStepState extends State { - final PocketBase pb = AppEngine.engine.pb; - final _formKey = GlobalKey(); - final _urlController = TextEditingController(); - final _nameController = TextEditingController( - text: "Stremio Addons", - ); - Connection? _existingConnection; - - bool _isLoading = false; - String? _errorMessage; - - final List> _addons = []; - - @override - void initState() { - super.initState(); - - loadExistingConnection(); - } - - loadExistingConnection() async { - try { - final existingConnection = - await pb.collection("connection").getFirstListItem( - "type.type = 'stremio_addons'", - ); - - final connection = Connection.fromRecord(existingConnection); - - _nameController.text = connection.title; - final config = connection.config; - - if (config['addons'] != null) { - for (var url in config['addons']) { - try { - await _validateAddonUrl(url); - } catch (e) { - print("Failed to load addon"); - } - } - } - - _existingConnection = connection; - } catch (e) { - if (e is! ClientException) { - rethrow; - } - } - } - - Future _validateAddonUrl(String url_) async { - setState(() { - _isLoading = true; - _errorMessage = null; - }); - - final url = url_.replaceFirst("stremio://", "https://"); - - try { - final response = await http.get( - Uri.parse( - url.replaceFirst("stremio://", "https://"), - ), - ); - if (response.statusCode == 200) { - final manifest = json.decode(response.body); - - final _manifest = StremioManifest.fromJson(manifest); - - if (manifest['name'] == null || manifest['id'] == null) { - throw 'Invalid addon manifest'; - } - - if (_addons.any((addon) => addon['url'] == url)) { - throw 'Addon already added to the list'; - } - - List supportedTypes = []; - - _manifest.resources?.forEach((item) { - supportedTypes.add(item.name); - }); - - setState(() { - _addons.add({ - 'name': _manifest.name, - 'icon': manifest['logo'] ?? manifest['icon'], - 'url': url, - 'addons': manifest, - 'manifestParsed': _manifest, - 'types': supportedTypes, - }); - _urlController.clear(); - }); - } else { - throw 'Failed to fetch addon manifest'; - } - } catch (e) { - setState(() { - _errorMessage = 'Invalid addon URL: ${e.toString()}'; - }); - } finally { - setState(() { - _isLoading = false; - }); - } - } - - Future showAddonWarningDialog( - BuildContext context, { - required bool isMeta, - required bool isAddon, - }) async { - bool continueAnyway = false; - - if (isMeta && isAddon) { - return true; - } - - await showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - title: const Text('Warning!'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (!isMeta || !isAddon) - const Text( - 'You are missing the following addons:', - style: TextStyle(fontWeight: FontWeight.bold), - ), - const SizedBox( - height: 4, - ), - if (!isMeta) const Text('🔴 Meta Addon'), - if (!isAddon) const Text('🔴 Streaming Addon'), - const SizedBox(height: 10), - const Text( - 'Continuing without these addons may limit functionality. Are you sure you want to proceed?', - style: TextStyle(color: Colors.red), - ), - ], - ), - actions: [ - TextButton( - onPressed: () { - // User chooses to continue anyway - Navigator.of(context).pop(); - continueAnyway = true; - }, - child: const Text('CONTINUE ANYWAY'), - ), - ElevatedButton( - onPressed: () { - // User chooses to add addon - Navigator.of(context).pop(); - continueAnyway = false; - }, - child: const Text('ADD ADDON'), - ), - ], - ); - }, - ); - - return continueAnyway; - } - - Future _saveConnection() async { - if (!_formKey.currentState!.validate() || _addons.isEmpty) return; - - bool hasMeta = false; - bool hasStream = false; - - for (final item in _addons) { - final manifest = item['manifestParsed'] as StremioManifest; - - if (manifest.resources == null) { - continue; - } - - for (final resource in manifest.resources!) { - if (resource.name == "meta") { - hasMeta = true; - } - - if (resource.name == "stream") { - hasStream = true; - } - } - } - - final result = await showAddonWarningDialog( - context, - isAddon: hasStream, - isMeta: hasMeta, - ); - - if (!result) { - return; - } - - setState(() { - _isLoading = true; - _errorMessage = null; - }); - - try { - final connectionType = - await pb.collection("connection_type").getFirstListItem( - "type = \"stremio_addons\"", - ); - - final body = { - 'title': _nameController.text, - 'user': pb.authStore.record!.id, - 'type': connectionType.id, - 'config': jsonEncode({ - 'addons': _addons.map((item) => item['url']).toList(), - }), - }; - - if (_existingConnection != null) { - // Update existing connection - await pb - .collection('connection') - .update(_existingConnection!.id, body: body); - } else { - // Create new connection - final result = await pb.collection('connection').create(body: body); - - _existingConnection = Connection.fromRecord(result); - } - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text("Connection saved successfully"), - ), - ); - } - - widget.onConnectionComplete( - Connection( - title: _nameController.text, - id: _existingConnection!.id ?? '', - config: jsonEncode({ - 'addons': _addons.map((item) => item['url']).toList(), - }), - type: 'stremio_addons', - ), - ); - } catch (e) { - setState(() { - _errorMessage = "Error: ${e.toString()}"; - }); - } finally { - setState(() { - _isLoading = false; - }); - } - } - - final Map _items = { - "Cinemeta": "https://v3-cinemeta.strem.io/manifest.json", - "Watchhub": "https://watchhub.strem.io/manifest.json", - "Subtitles": "https://opensubtitles-v3.strem.io/manifest.json", - }; - - void _removeAddon(int index) { - setState(() { - _addons.removeAt(index); - }); - } - - void _reorderAddon(int oldIndex, int newIndex) { - setState(() { - if (oldIndex < newIndex) { - newIndex -= 1; - } - final item = _addons.removeAt(oldIndex); - _addons.insert(newIndex, item); - }); - } - - @override - Widget build(BuildContext context) { - return Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: - MainAxisSize.min, // Add this to shrink-wrap the Column - children: [ - TextFormField( - controller: _nameController, - decoration: const InputDecoration( - labelText: 'Connection Name', - ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter a connection name'; - } - return null; - }, - ), - const SizedBox(height: 20), - TextFormField( - controller: _urlController, - decoration: InputDecoration( - labelText: 'Addon URL', - hintText: 'https://example.com/manifest.json', - suffixIcon: IconButton( - icon: const Icon(Icons.add), - onPressed: () => _validateAddonUrl(_urlController.text), - ), - ), - validator: (value) { - if (_addons.isEmpty) { - return 'Please add at least one addon'; - } - if (value != null && value.isNotEmpty) { - try { - final uri = Uri.parse(value); - if (!uri.isScheme('http') && !uri.isScheme('https')) { - return 'Please enter a valid HTTP/HTTPS URL'; - } - } catch (e) { - return 'Please enter a valid URL'; - } - } - return null; - }, - ), - const SizedBox(height: 12), - SizedBox( - height: 36, - child: ListView.builder( - itemCount: _items.length, - scrollDirection: Axis.horizontal, - itemBuilder: (context, index) { - return Container( - margin: const EdgeInsets.only(right: 4), - child: ActionChip( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - label: Text(_items.keys.toList()[index]), - avatar: const Icon(Icons.add), - onPressed: () { - _validateAddonUrl(_items.values.toList()[index]); - }, - ), - ); - }, - ), - ), - if (_isLoading) - const Center( - child: Padding( - padding: EdgeInsets.only(top: 12), - child: CircularProgressIndicator(), - ), - ), - if (_errorMessage != null) - Padding( - padding: const EdgeInsets.only(top: 8.0), - child: Text( - _errorMessage!, - style: const TextStyle(color: Colors.red), - ), - ), - const SizedBox(height: 20), - if (_addons.isNotEmpty) ...[ - const Text( - 'Added Addons:', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 10), - Flexible( - fit: FlexFit.loose, - child: ReorderableListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: _addons.length, - onReorder: _reorderAddon, - itemBuilder: (context, index) { - final addon = _addons[index]; - final name = utf8.decode( - (addon['name'] ?? 'Unknown Addon').runes.toList(), - ); - - return Card( - key: Key('$index'), - margin: EdgeInsets.only( - bottom: index + 1 != _addons.length ? 10 : 0, - ), - child: ListTile( - leading: addon['icon'] != null - ? Image.network( - addon['icon'], - width: 40, - height: 40, - errorBuilder: (_, __, ___) => - const Icon(Icons.extension), - ) - : const Icon(Icons.extension, size: 40), - title: Text( - name, - maxLines: 1, - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - addon['url'], - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox( - height: 4, - ), - SizedBox( - height: 40, - child: ListView( - scrollDirection: Axis.horizontal, - children: [ - for (int i = 0; - i < addon['types'].length; - i++) - Padding( - padding: - const EdgeInsets.only(right: 4), - child: RawChip( - padding: EdgeInsets.zero, - label: Text( - (addon['types'][i] as String) - .capitalize(), - ), - visualDensity: VisualDensity.compact, - ), - ), - ], - ), - ) - ], - ), - trailing: IconButton( - icon: const Icon(Icons.remove_circle_outline, - color: Colors.red), - onPressed: () => _removeAddon(index), - ), - ), - ); - }, - ), - ), - ], - Padding( - padding: const EdgeInsets.only( - bottom: 12.0, - top: 12.0, - ), - child: ElevatedButton( - onPressed: _addons.isEmpty ? null : _saveConnection, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.white70, - foregroundColor: Colors.black, - ), - child: Text( - 'Next', - style: GoogleFonts.exo2().copyWith( - fontWeight: FontWeight.w600, - fontSize: 16, - ), - ), - ), - ), - ], - ), - ], - ), - ); - } - - @override - void dispose() { - _urlController.dispose(); - _nameController.dispose(); - super.dispose(); - } -} diff --git a/lib/features/getting_started/container/getting_started.dart b/lib/features/getting_started/container/getting_started.dart deleted file mode 100644 index 16d8aea..0000000 --- a/lib/features/getting_started/container/getting_started.dart +++ /dev/null @@ -1,248 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; -import 'package:madari_client/features/connection/containers/auto_import.dart'; - -import '../../settings/types/connection.dart'; -import 'create_connection.dart'; - -class GettingStartedScreen extends StatefulWidget { - final VoidCallback onCallback; - final bool hasBackground; - - const GettingStartedScreen({ - super.key, - required this.onCallback, - this.hasBackground = true, - }); - - @override - State createState() => _GettingStartedScreenState(); -} - -class _GettingStartedScreenState extends State - with TickerProviderStateMixin { - final PageController _pageController = PageController(); - int _currentPage = 0; - late AnimationController _animationController; - Connection? _connection; - - late final List steps = [ - OnboardingStep( - key: 'create_connection', - title: 'Setup Connection', - description: 'Configure your Stremio addons', - icon: Icons.link_rounded, - gradientColors: [Colors.purple.shade800, Colors.blue.shade900], - ), - OnboardingStep( - key: 'create_library', - title: 'Create Library', - description: 'Organize your data into libraries for better management', - icon: Icons.library_books_rounded, - gradientColors: [Colors.blue.shade900, Colors.teal.shade800], - ), - ]; - - @override - void initState() { - super.initState(); - _animationController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 2000), - )..repeat(); - } - - @override - void dispose() { - _animationController.dispose(); - _pageController.dispose(); - super.dispose(); - } - - void _nextPage() { - if (_currentPage < steps.length - 1) { - _pageController.nextPage( - duration: const Duration(milliseconds: 500), - curve: Curves.easeInOut, - ); - } - } - - void _previousPage() { - if (_currentPage > 0) { - _pageController.previousPage( - duration: const Duration(milliseconds: 500), - curve: Curves.easeInOut, - ); - } - } - - @override - Widget build(BuildContext context) { - final isDesktop = MediaQuery.of(context).size.width > 800; - - return Stack( - children: [ - if (widget.hasBackground) - AnimatedContainer( - duration: const Duration(milliseconds: 500), - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: steps[_currentPage].gradientColors, - ), - ), - ), - // Content - Center( - child: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: isDesktop ? 1200 : double.infinity, - maxHeight: 800, - ), - child: Card( - margin: EdgeInsets.symmetric( - horizontal: isDesktop ? 48.0 : 0, - vertical: isDesktop ? 32.0 : 0, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(32), - ), - color: isDesktop ? null : Colors.transparent, - elevation: 0, - child: ClipRRect( - borderRadius: BorderRadius.circular(32), - child: Stack( - children: [ - Stack( - children: [ - PageView.builder( - physics: const NeverScrollableScrollPhysics(), - controller: _pageController, - onPageChanged: (index) { - setState(() => _currentPage = index); - _animationController.reset(); - _animationController.forward(); - }, - itemCount: steps.length, - itemBuilder: (context, index) { - return _buildPage(steps[index], index); - }, - ), - ], - ), - ], - ), - ), - ), - ), - ), - ], - ); - } - - Widget _buildPage( - OnboardingStep step, - int index, - ) { - return SingleChildScrollView( - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 22), - Padding( - padding: const EdgeInsets.all(12.0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - if (index != 0) - IconButton( - onPressed: () { - _previousPage(); - }, - icon: const Icon( - Icons.arrow_back, - ), - ), - const SizedBox( - width: 6, - ), - Text( - step.title, - textAlign: TextAlign.start, - style: GoogleFonts.poppins( - fontSize: 22, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - ], - ), - ), - const SizedBox(height: 4), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 20, - ), - child: Text( - step.description, - textAlign: TextAlign.start, - style: GoogleFonts.poppins( - fontSize: 16, - color: Colors.white.withOpacity(0.8), - ), - ), - ), - const SizedBox(height: 0), - if (step.key == 'create_library') - ConstrainedBox( - constraints: BoxConstraints( - maxHeight: MediaQuery.of(context).size.height * - 0.6, // Adjust this value as needed - ), - child: Padding( - padding: const EdgeInsets.only(top: 8.0), - child: AutoImport( - item: _connection!, - onImport: () { - widget.onCallback(); - }, - ), - ), - ), - if (step.key == 'create_connection') - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 20, - ), - child: CreateConnectionStep( - onConnectionComplete: (Connection connection) { - _connection = connection; - _nextPage(); - }, - ), - ), - ], - ), - ); - } -} - -class OnboardingStep { - final String title; - final String description; - final IconData icon; - final List gradientColors; - final String key; - - OnboardingStep({ - required this.title, - required this.description, - required this.icon, - required this.gradientColors, - required this.key, - }); -} diff --git a/lib/features/home/pages/home_page.dart b/lib/features/home/pages/home_page.dart new file mode 100644 index 0000000..f1aa19a --- /dev/null +++ b/lib/features/home/pages/home_page.dart @@ -0,0 +1,189 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:madari_client/features/settings/service/selected_profile.dart'; +import 'package:provider/provider.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import '../../widgetter/plugin_layout.dart'; +import '../../widgetter/state/widget_state_provider.dart'; + +class HomePage extends StatefulWidget { + final bool hasSearch; + final bool isExplore; + + const HomePage({ + super.key, + this.hasSearch = false, + this.isExplore = false, + }); + + @override + State createState() => _HomePageState(); +} + +class _HomePageState extends State { + final _state = GlobalKey(); + + late StreamSubscription _selectedProfile; + + Widget _buildLogo() { + return Image.asset( + 'assets/icon/icon_mini.png', + height: 32, + fit: BoxFit.contain, + ); + } + + @override + void initState() { + _selectedProfile = + SelectedProfileService.instance.selectedProfileStream.listen((data) { + _state.currentState?.refresh(); + }); + + super.initState(); + } + + @override + void dispose() { + super.dispose(); + _selectedProfile.cancel(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + elevation: 0, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + title: Row( + children: [ + _buildLogo(), + const SizedBox( + width: 12, + ), + const Text('Madari'), + ], + ), + actions: [ + if (UniversalPlatform.isDesktop) + IconButton( + onPressed: () { + _state.currentState?.refresh(); + }, + icon: const Icon(Icons.refresh), + ), + IconButton( + onPressed: () { + context.push("/downloads"); + }, + icon: const Icon(Icons.download_rounded), + ), + ], + ), + body: LayoutManager( + key: _state, + hasSearch: widget.hasSearch, + ), + ); + } +} + +class SearchBox extends StatefulWidget { + final String? hintText; + final EdgeInsetsGeometry? padding; + final double? height; + + const SearchBox({ + super.key, + this.hintText, + this.padding, + this.height, + }); + + @override + State createState() => _SearchBoxState(); +} + +class _SearchBoxState extends State { + Timer? _debounce; + final TextEditingController _controller = TextEditingController(); + + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + final provider = context.read(); + _controller.text = provider.search; + }); + } + + void _onSearchChanged(String value, StateProvider provider) { + if (_debounce?.isActive ?? false) _debounce?.cancel(); + _debounce = Timer(const Duration(milliseconds: 800), () { + provider.setSearch(value); + }); + } + + void _clearSearch(StateProvider provider) { + _controller.clear(); + provider.setSearch(''); + } + + @override + void dispose() { + _debounce?.cancel(); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Padding( + padding: widget.padding ?? + const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Consumer( + builder: (context, provider, _) { + return SearchBar( + controller: _controller, + hintText: widget.hintText ?? 'Search...', + padding: WidgetStateProperty.all( + const EdgeInsets.symmetric(horizontal: 16), + ), + onChanged: (value) => _onSearchChanged(value, provider), + leading: Icon( + Icons.search, + color: colorScheme.onSurfaceVariant, + ), + trailing: [ + if (provider.search.trim() != "") + IconButton( + icon: Icon( + Icons.clear, + color: colorScheme.onSurfaceVariant, + ), + onPressed: () => _clearSearch(provider), + ), + ], + elevation: WidgetStateProperty.all(0), + backgroundColor: WidgetStateProperty.all( + colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), + ), + constraints: BoxConstraints.tightFor( + height: widget.height ?? 46, + ), + shape: WidgetStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(28), + ), + ), + ); + }, + ), + ); + } +} diff --git a/lib/features/home/screen/home_items.dart b/lib/features/home/screen/home_items.dart deleted file mode 100644 index 0796397..0000000 --- a/lib/features/home/screen/home_items.dart +++ /dev/null @@ -1,318 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:madari_client/features/connection/services/base_connection_service.dart'; -import 'package:madari_client/features/connection/services/stremio_service.dart'; -import 'package:madari_client/features/library_item/container/stremio_item_card.dart'; -import 'package:shimmer/shimmer.dart'; - -import '../../../engine/library.dart'; -import '../../connections/types/stremio/stremio_base.types.dart'; -import '../../library_item/container/item_list.dart'; -import '../../library_item/container/item_viewer.dart'; - -class HomeItems extends ConsumerStatefulWidget { - final LibraryRecord library; - const HomeItems({ - super.key, - required this.library, - }); - - @override - createState() => _HomeItemsState(); -} - -class _HomeItemsState extends ConsumerState { - List _items = []; - bool _hasInitiallyLoaded = false; - late BaseConnectionService _client; - bool _unsupportedClient = false; - - @override - void initState() { - super.initState(); - - Future.microtask(() async { - _fetchInitialItems(); - }); - } - - double _getItemWidth(BuildContext context) { - double screenWidth = MediaQuery.of(context).size.width; - return screenWidth > 800 ? 200.0 : 120.0; - } - - double _getListHeight(BuildContext context) { - double screenWidth = MediaQuery.of(context).size.width; - return screenWidth > 800 ? 300.0 : 180.0; - } - - void _fetchInitialItems() async { - if (widget.library.connection == "telegram" && - (kIsWeb || !Platform.isAndroid)) { - setState(() { - _items = []; - _hasInitiallyLoaded = true; - _unsupportedClient = true; - }); - } - - if (!mounted) { - return; - } - - final result = ref.read( - libraryItemListProvider( - widget.library, - _items, - 1, - "", - ), - ); - - if (result.value != null && mounted) { - setState(() { - _items = result.value!.items; - _hasInitiallyLoaded = true; - }); - } - - ref - .read(libraryItemListProvider( - widget.library, - _items, - 1, // First page only - null, - ).future) - .then((result) { - if (mounted) { - Future.microtask(() { - setState(() { - _items = result.items; - _hasInitiallyLoaded = true; - }); - }); - } - }); - } - - StremioService? _item; - - StremioService get service { - if (_item != null) { - return _item!; - } - _item = _client as StremioService; - return _item!; - } - - @override - Widget build(BuildContext context) { - final itemWidth = _getItemWidth(context); - final listHeight = _getListHeight(context); - - if (_items.isEmpty && _hasInitiallyLoaded) { - return SizedBox( - height: listHeight, - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.hourglass_empty, - size: 60, - color: Colors.grey, - ), - const SizedBox(height: 16), - Text( - _unsupportedClient - ? 'Telegram is not supported' - : 'No items found', - style: const TextStyle( - fontSize: 18, - color: Colors.grey, - ), - ), - ], - ), - ), - ); - } - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - widget.library.title, - style: Theme.of(context).textTheme.titleLarge, - ), - TextButton( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ItemList( - library: widget.library, - ), - ), - ); - }, - child: const Text('See All'), - ), - ], - ), - ), - const SizedBox(height: 8), - SizedBox( - height: listHeight, - child: _items.isEmpty && !_hasInitiallyLoaded - ? _buildLoadingList(itemWidth) - : ListView.builder( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 12), - itemCount: _items.length, - itemBuilder: (context, index) { - final item = _items[index]; - - if (widget.library.connectionType == "stremio_addons") { - final parsed = Meta.fromJson( - jsonDecode(item.config!), - ); - return StremioItemCard( - item: item, - parsed: parsed, - service: service, - heroPrefix: widget.library.id, - ); - } - - return Container( - margin: const EdgeInsets.only(right: 6), - child: SizedBox( - width: itemWidth, - child: Card( - margin: const EdgeInsets.symmetric(horizontal: 4), - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: Stack( - children: [ - InkWell( - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ItemViewer( - item: item, - library: widget.library, - ), - ), - ); - }, - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - if (item.logo != null) - Expanded( - child: AspectRatio( - aspectRatio: 10 / 2, - child: ClipRRect( - borderRadius: - BorderRadius.circular(10), - child: Hero( - tag: item.id, - child: _buildImage(item.logo!), - ), - ), - ), - ), - if (item.logo == null) - const Expanded( - child: Center( - child: Icon( - Icons.video_library, - size: 44, - ), - ), - ) - ], - ), - ), - if (item.history != null) - Positioned( - child: LinearProgressIndicator( - minHeight: 4, - value: - (item.history?.progress ?? 0) / 100, - ), - ), - ], - ), - ), - ), - ), - ); - }, - ), - ), - ], - ); - } - - Widget _buildLoadingList(double itemWidth) { - return ListView.builder( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 12), - itemCount: 14, - itemBuilder: (context, index) => SizedBox( - width: itemWidth, - child: Card( - margin: const EdgeInsets.symmetric(horizontal: 4), - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: Shimmer.fromColors( - baseColor: Colors.grey[800]!, - highlightColor: Colors.grey[600]!, - child: Container( - color: Colors.grey[800], - ), - ), - ), - ), - ), - ); - } - - Widget _buildImage(String logo) { - if (logo == "") { - return const Center( - child: Icon(Icons.browse_gallery), - ); - } - - if (logo.startsWith("/9j")) { - return Image.memory( - base64Decode(logo), - fit: BoxFit.cover, - ); - } else if (logo.startsWith("http://") || logo.startsWith("https://")) { - return Image.network( - logo, - fit: BoxFit.cover, - ); - } else { - return Image.file( - File(logo), - fit: BoxFit.cover, - ); - } - } -} diff --git a/lib/features/layout/data/navigation_items.dart b/lib/features/layout/data/navigation_items.dart new file mode 100644 index 0000000..cab0264 --- /dev/null +++ b/lib/features/layout/data/navigation_items.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +import '../models/navigation.model.dart'; + +final navigationItems = [ + const NavigationItem( + label: 'Home', + path: '/', + icon: Icons.home_outlined, + selectedIcon: Icons.home, + ), + const NavigationItem( + label: 'Search', + path: '/search', + icon: Icons.search_outlined, + selectedIcon: Icons.search, + ), + const NavigationItem( + label: 'Explore', + path: '/explore', + icon: Icons.explore_outlined, + selectedIcon: Icons.explore, + ), + const NavigationItem( + label: 'Library', + path: '/library', + icon: Icons.video_library_outlined, + selectedIcon: Icons.video_library, + ), + const NavigationItem( + label: 'Settings', + path: '/settings', + icon: Icons.settings_outlined, + selectedIcon: Icons.settings, + ), +]; diff --git a/lib/features/layout/models/device_type.dart b/lib/features/layout/models/device_type.dart new file mode 100644 index 0000000..cb4a772 --- /dev/null +++ b/lib/features/layout/models/device_type.dart @@ -0,0 +1 @@ +enum DeviceType { mobile, desktop } diff --git a/lib/features/layout/models/navigation.model.dart b/lib/features/layout/models/navigation.model.dart new file mode 100644 index 0000000..1431f64 --- /dev/null +++ b/lib/features/layout/models/navigation.model.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; + +class NavigationItem { + final String label; + final String path; + final IconData icon; + final IconData? selectedIcon; + + const NavigationItem({ + required this.label, + required this.path, + required this.icon, + this.selectedIcon, + }); +} diff --git a/lib/features/layout/widgets/desktop_navigation.dart b/lib/features/layout/widgets/desktop_navigation.dart new file mode 100644 index 0000000..3850993 --- /dev/null +++ b/lib/features/layout/widgets/desktop_navigation.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; + +import '../models/navigation.model.dart'; + +class DesktopNavigation extends StatelessWidget { + final List items; + final int currentIndex; + final ValueChanged onNavigate; + + const DesktopNavigation({ + super.key, + required this.items, + required this.onNavigate, + required this.currentIndex, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return AppBar( + backgroundColor: theme.colorScheme.surface.withValues(alpha: 0.8), + elevation: 0, + title: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: items.map((item) { + final index = items.indexOf(item); + final isSelected = index == currentIndex; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: TextButton.icon( + onPressed: () => onNavigate(index), + icon: Icon( + isSelected ? item.selectedIcon ?? item.icon : item.icon, + color: isSelected ? theme.colorScheme.primary : null, + ), + label: Text( + item.label, + style: TextStyle( + color: isSelected ? theme.colorScheme.primary : null, + ), + ), + style: TextButton.styleFrom( + backgroundColor: isSelected + ? theme.colorScheme.primaryContainer.withValues( + alpha: 0.2, + ) + : null, + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 8.0, + ), + ), + ), + ); + }).toList(), + ), + ); + } +} diff --git a/lib/features/layout/widgets/mobile_navigation.dart b/lib/features/layout/widgets/mobile_navigation.dart new file mode 100644 index 0000000..59e7b14 --- /dev/null +++ b/lib/features/layout/widgets/mobile_navigation.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; + +import '../models/navigation.model.dart'; + +class MobileNavigation extends StatelessWidget { + final List items; + final int currentIndex; + final ValueChanged onNavigate; + + const MobileNavigation({ + super.key, + required this.items, + required this.currentIndex, + required this.onNavigate, + }); + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.circular(20), + ), + child: NavigationBar( + selectedIndex: currentIndex, + onDestinationSelected: (index) { + onNavigate(index); + }, + height: 58, + labelBehavior: NavigationDestinationLabelBehavior.alwaysHide, + destinations: items + .map((item) => NavigationDestination( + icon: Icon(item.icon), + selectedIcon: item.selectedIcon != null + ? Icon(item.selectedIcon) + : null, + label: item.label, + )) + .toList(), + ), + ); + } +} diff --git a/lib/features/layout/widgets/scaffold_with_nav.dart b/lib/features/layout/widgets/scaffold_with_nav.dart new file mode 100644 index 0000000..019db02 --- /dev/null +++ b/lib/features/layout/widgets/scaffold_with_nav.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import '../../widgetter/state/widget_state_provider.dart'; +import '../data/navigation_items.dart'; +import '../models/device_type.dart'; +import 'desktop_navigation.dart'; +import 'mobile_navigation.dart'; + +class ScaffoldWithNav extends StatefulWidget { + final StatefulNavigationShell child; + + const ScaffoldWithNav({ + super.key, + required this.child, + }); + + @override + State createState() => _ScaffoldWithNavState(); +} + +class _ScaffoldWithNavState extends State { + DateTime? currentBackPressTime; + bool canPopNow = false; + static const int requiredSeconds = 2; + + DeviceType _getDeviceType() { + if (UniversalPlatform.isIOS || UniversalPlatform.isAndroid) { + return DeviceType.mobile; + } + + return DeviceType.desktop; + } + + @override + void initState() { + super.initState(); + } + + String previousSearch = ""; + + onNavigate(int index) { + widget.child.goBranch(index); + + final contextData = context.read(); + + if (index == 0) { + previousSearch = contextData.search; + contextData.setSearch(""); + } else if (index == 1) { + contextData.setSearch(previousSearch); + } + } + + void onPopInvoked(bool didPop) { + DateTime now = DateTime.now(); + if (currentBackPressTime == null || + now.difference(currentBackPressTime!) > + const Duration(seconds: requiredSeconds)) { + currentBackPressTime = now; + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Tap back again to leave"), + duration: Duration(seconds: 2), + ), + ); + Future.delayed( + const Duration(seconds: requiredSeconds), + () { + setState(() { + canPopNow = false; + }); + }, + ); + setState(() { + canPopNow = true; + }); + } + } + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: canPopNow, + onPopInvokedWithResult: (c, cc) => onPopInvoked(c), + child: _buildBody(context), + ); + } + + Widget _buildBody(BuildContext context) { + final deviceType = _getDeviceType(); + + switch (deviceType) { + case DeviceType.mobile: + return Scaffold( + body: widget.child, + extendBody: true, + bottomNavigationBar: MobileNavigation( + items: navigationItems, + currentIndex: widget.child.currentIndex, + onNavigate: onNavigate, + ), + ); + + case DeviceType.desktop: + return Scaffold( + appBar: PreferredSize( + preferredSize: const Size.fromHeight(kToolbarHeight), + child: DesktopNavigation( + items: navigationItems, + currentIndex: widget.child.currentIndex, + onNavigate: onNavigate, + ), + ), + body: widget.child, + ); + } + } +} diff --git a/lib/features/layout/widgets/tv_navigation.dart b/lib/features/layout/widgets/tv_navigation.dart new file mode 100644 index 0000000..1c4d491 --- /dev/null +++ b/lib/features/layout/widgets/tv_navigation.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; + +import '../models/navigation.model.dart'; + +class TVNavigation extends StatefulWidget { + final List items; + final String currentLocation; + final ValueChanged onNavigate; + final bool isTV; + + const TVNavigation({ + super.key, + required this.items, + required this.currentLocation, + required this.onNavigate, + this.isTV = false, + }); + + @override + State createState() => _TVNavigationState(); +} + +class _TVNavigationState extends State { + bool _isExpanded = true; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: _isExpanded ? 240.0 : 72.0, + child: Drawer( + elevation: 0, + child: Column( + crossAxisAlignment: _isExpanded + ? CrossAxisAlignment.start + : CrossAxisAlignment.center, + children: [ + if (!widget.isTV) + Padding( + padding: const EdgeInsets.all(8.0), + child: IconButton( + icon: Icon( + _isExpanded ? Icons.chevron_left : Icons.chevron_right), + onPressed: () => setState(() => _isExpanded = !_isExpanded), + ), + ), + Expanded( + child: ListView( + children: widget.items.map((item) { + final isSelected = + widget.currentLocation.startsWith(item.path); + return ListTile( + selected: isSelected, + leading: Icon( + isSelected ? item.selectedIcon ?? item.icon : item.icon, + color: isSelected ? theme.colorScheme.primary : null, + ), + title: _isExpanded ? Text(item.label) : null, + onTap: () => widget.onNavigate(0), + autofocus: widget.isTV && isSelected, + selectedTileColor: + theme.colorScheme.primaryContainer.withValues( + alpha: 0.2, + ), + ); + }).toList(), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/library/component/library_search.dart b/lib/features/library/component/library_search.dart deleted file mode 100644 index 4dd9b84..0000000 --- a/lib/features/library/component/library_search.dart +++ /dev/null @@ -1,272 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:madari_client/features/connection/services/base_connection_service.dart'; - -import '../../../engine/library.dart'; -import '../../connection/services/stremio_service.dart'; -import '../../connection/types/stremio.dart'; -import '../../connections/types/stremio/stremio_base.types.dart'; -import '../../library_item/container/stremio_item_list.dart'; - -class LibraryItemSearchDelegate extends SearchDelegate { - final LibraryRecord library; - final List items; - final WidgetRef ref; - Timer? _debounceTimer; - final _debounceDuration = const Duration(milliseconds: 300); - String _debouncedQuery = ''; - BaseConnectionService? service; - - LibraryItemSearchDelegate({ - required this.library, - required this.items, - required this.ref, - this.service, - }); - - @override - void dispose() { - _debounceTimer?.cancel(); - super.dispose(); - } - - @override - List buildActions(BuildContext context) { - return [ - IconButton( - icon: const Icon(Icons.clear), - onPressed: () { - query = ''; - }, - ), - ]; - } - - @override - Widget buildLeading(BuildContext context) { - return IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () { - close(context, null); - }, - ); - } - - @override - Widget buildResults(BuildContext context) { - return _buildSearchResults(); - } - - double _calculateRelevance(LibraryItemList item, String searchQuery) { - final queryLower = searchQuery.toLowerCase(); - final titleLower = item.title.toLowerCase(); - final extraLower = (item.extra ?? '').toLowerCase(); - - double score = 0.0; - - // Exact matches get highest score - if (titleLower == queryLower) { - score += 100; - } - // Title starts with query - else if (titleLower.startsWith(queryLower)) { - score += 75; - } - // Title contains query - else if (titleLower.contains(queryLower)) { - score += 50; - } - - // Extra field matches - if (extraLower.contains(queryLower)) { - score += 25; - } - - if (item.popularity != null) { - score += (item.popularity!) * 10000000; - } - - return score; - } - - @override - Widget buildSuggestions(BuildContext context) { - return _buildSearchResults(); - } - - Widget _buildSearchResults() { - if (query.isEmpty) { - return const Center( - child: Text('Type to search...'), - ); - } - - return LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - final isMobile = constraints.maxWidth < 600; - - return StreamBuilder( - stream: Stream.fromFuture(() async { - // Create a completer that will be completed after debounce - final completer = Completer(); - - // Cancel any existing timer - _debounceTimer?.cancel(); - - // Only update if query changed - if (_debouncedQuery != query) { - _debounceTimer = Timer(_debounceDuration, () { - _debouncedQuery = query; - completer.complete(); - }); - } else { - completer.complete(); - } - - await completer.future; - - return ref.read( - libraryItemListProvider( - library, - items, - 1, - _debouncedQuery, - ).future, - ); - }()), - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } - - if (snapshot.hasError) { - return Center(child: Text('Error: ${snapshot.error}')); - } - - final searchResults = snapshot.data?.items ?? []; - - if (searchResults.isEmpty) { - return const Center(child: Text('No results found')); - } - - searchResults.sort((a, b) { - final scoreA = _calculateRelevance(a, query); - final scoreB = _calculateRelevance(b, query); - return scoreB.compareTo(scoreA); - }); - - return GridView.builder( - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: isMobile - ? 1 - : (constraints.maxWidth ~/ 300), // Reduced from 300 to 200 - childAspectRatio: - isMobile ? 2.3 : 1.63, // Adjusted ratios for both layouts - mainAxisSpacing: 4, // Reduced spacing - crossAxisSpacing: 4, - ), - padding: const EdgeInsets.all(8), - itemCount: searchResults.length, - itemBuilder: (context, index) { - final item = searchResults[index]; - - if (library.connectionType == "stremio_addons") { - final parsed = Meta.fromJson(jsonDecode(item.config!)); - return StremioItemList( - item: item, - parsed: parsed, - service: service as StremioService, - ); - } - - return Card( - margin: - const EdgeInsets.symmetric(vertical: 2, horizontal: 4), - child: InkWell( - onTap: () => close(context, item), - child: isMobile - ? ListTile( - contentPadding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - leading: item.logo != null - ? SizedBox( - width: 40, // Fixed size for consistency - height: 40, - child: Image.network( - item.logo!, - fit: BoxFit.cover, - ), - ) - : null, - title: Text( - item.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 14), - ), - subtitle: item.extra != null - ? Text( - item.extra!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 12), - ) - : null, - ) - : Padding( - padding: const EdgeInsets.all(8.0), - child: Row( - children: [ - if (item.logo != null) - SizedBox( - width: 50, // Fixed size for desktop - height: 50, - child: Image.network( - item.logo!, - fit: BoxFit.cover, - ), - ), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - item.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - ), - ), - if (item.extra != null) - Text( - item.extra!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 12), - ), - ], - ), - ), - ], - ), - ), - ), - ); - }, - ); - }, - ); - }, - ); - } -} diff --git a/lib/features/library/component/libray_card.dart b/lib/features/library/component/libray_card.dart deleted file mode 100644 index ef7b823..0000000 --- a/lib/features/library/component/libray_card.dart +++ /dev/null @@ -1,58 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:madari_client/engine/engine.dart'; -import 'package:madari_client/engine/library.dart'; -import 'package:madari_client/features/library_item/container/item_list.dart'; -import 'package:pocketbase/pocketbase.dart'; - -class LibraryCard extends StatefulWidget { - final LibraryRecord library; - - const LibraryCard({ - super.key, - required this.library, - }); - - @override - State createState() => _LibraryCardState(); -} - -class _LibraryCardState extends State { - final PocketBase pb = AppEngine.engine.pb; - - @override - Widget build(BuildContext context) { - return Card( - elevation: 3, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(18), - ), - child: InkWell( - borderRadius: BorderRadius.circular(18), - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => ItemList( - library: widget.library, - ), - ), - ); - }, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 16), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - widget.library.title, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/features/library/container/add_to_list_button.dart b/lib/features/library/container/add_to_list_button.dart new file mode 100644 index 0000000..c3a88e1 --- /dev/null +++ b/lib/features/library/container/add_to_list_button.dart @@ -0,0 +1,446 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:logging/logging.dart'; + +import '../../streamio_addons/models/stremio_base_types.dart'; +import '../service/list_service.dart'; +import '../types/library_types.dart'; + +class AddToListButton extends StatefulWidget { + final Meta meta; + final Widget? child; + final IconData? icon; + final bool useDialog; + final Function()? onAdded; + final Function()? onRemoved; + final String? listName; + final Widget? label; + + const AddToListButton({ + super.key, + required this.meta, + this.child, + this.icon, + this.useDialog = false, + this.onAdded, + this.onRemoved, + this.listName, + this.label, + }) : assert( + listName != null || child != null || icon != null, + 'Either listName, child, or icon must be provided', + ); + + @override + State createState() => _AddToListButtonState(); +} + +class _AddToListButtonState extends State { + final _logger = Logger('AddToListButton'); + List _lists = []; + bool _isLoading = false; + bool _existsInList = false; + String? _existingItemId; + ListModel? _existingList; + + @override + void initState() { + super.initState(); + if (widget.listName != null) { + _checkIfExists(); + } + } + + Future _checkIfExists() async { + try { + setState(() => _isLoading = true); + + final lists = await ListsService.instance.getLists(); + _existingList = lists.cast().firstWhere( + (list) => + list?.name.toLowerCase() == widget.listName?.toLowerCase(), + orElse: () => null, + ); + + if (_existingList != null) { + final items = + await ListsService.instance.getListItems(_existingList!.id); + final existingItem = items.cast().firstWhere( + (item) => item?.imdbId == widget.meta.imdbId, + orElse: () => null, + ); + + setState(() { + _existsInList = existingItem != null; + _existingItemId = existingItem?.id; + _isLoading = false; + }); + } else { + setState(() => _isLoading = false); + } + } catch (e) { + _logger.severe('Error checking if item exists', e); + setState(() => _isLoading = false); + } + } + + Future _loadLists() async { + try { + setState(() => _isLoading = true); + _lists = await ListsService.instance.getLists(); + setState(() => _isLoading = false); + } catch (e) { + _logger.severe('Error loading lists', e); + setState(() => _isLoading = false); + } + } + + Future _createAndAddToList( + BuildContext context, String listName) async { + try { + setState(() => _isLoading = true); + + final lists = await ListsService.instance.getLists(); + ListModel? list = lists.cast().firstWhere( + (list) => list?.name.toLowerCase() == listName.toLowerCase(), + orElse: () => null, + ); + + if (list == null) { + final request = CreateListRequest( + name: listName, + description: '', + ); + await ListsService.instance.createList(request); + + final updatedLists = await ListsService.instance.getLists(); + list = updatedLists.firstWhere( + (l) => l.name.toLowerCase() == listName.toLowerCase(), + ); + } + + if (!context.mounted) return; + + await _addToList(context, list); + setState(() => _isLoading = false); + await _checkIfExists(); + } catch (e) { + _logger.severe('Error creating/adding to list', e); + setState(() => _isLoading = false); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Failed to add to list'), + behavior: SnackBarBehavior.floating, + ), + ); + } + } + } + + Future _addToList(BuildContext context, ListModel list) async { + try { + final item = ListItemModel( + id: '', // Will be generated by PocketBase + type: widget.meta.type, + imdbId: widget.meta.imdbId ?? '', + ids: { + 'imdb': widget.meta.imdbId, + 'tmdb': widget.meta.moviedbId?.toString(), + 'tvdb': widget.meta.tvdbId?.toString(), + }, + title: widget.meta.name ?? '', + description: widget.meta.description ?? '', + poster: widget.meta.poster ?? '', + rating: (double.tryParse((widget.meta.imdbRating_).toString())) + ?.toDouble() ?? + 0.0, + ); + + await ListsService.instance.addListItem(list.id, item); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Added to ${list.name}'), + behavior: SnackBarBehavior.floating, + action: SnackBarAction( + label: 'View', + onPressed: () => context.push('/library/${list.id}', extra: list), + ), + ), + ); + + widget.onAdded?.call(); + } + } catch (e, stack) { + _logger.severe('Error adding to list', e, stack); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Failed to add to list'), + behavior: SnackBarBehavior.floating, + ), + ); + } + } + } + + Future _removeFromList( + BuildContext context, String itemId, ListModel list) async { + try { + setState(() => _isLoading = true); + await ListsService.instance.removeListItem(list.id, itemId); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Removed from ${list.name}'), + behavior: SnackBarBehavior.floating, + ), + ); + widget.onRemoved?.call(); + } + + setState(() { + _existsInList = false; + _existingItemId = null; + _isLoading = false; + }); + } catch (e) { + _logger.severe('Error removing from list', e); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Failed to remove from list'), + behavior: SnackBarBehavior.floating, + ), + ); + } + setState(() => _isLoading = false); + } + } + + Future _showListsDialog(BuildContext context) async { + await _loadLists(); + if (!mounted) return; + + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Add to List'), + content: SizedBox( + width: double.maxFinite, + height: MediaQuery.of(context).size.height * 0.4, + child: _isLoading + ? const Center( + child: CircularProgressIndicator(), + ) + : _lists.isEmpty + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.folder_outlined, + size: 48, + color: colorScheme.primary.withAlpha(150), + ), + const SizedBox(height: 16), + Text( + 'No Lists Yet', + style: theme.textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + 'Create a list to start organizing', + style: theme.textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurface.withAlpha(150), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: () { + Navigator.pop(context); + context.push('/library/create'); + }, + icon: const Icon(Icons.add), + label: const Text('Create New List'), + ), + ], + ), + ) + : ListView.separated( + itemCount: _lists.length, + separatorBuilder: (context, index) => + const Divider(height: 1), + itemBuilder: (context, index) { + final list = _lists[index]; + return ListTile( + leading: Icon( + _getListIcon(list.name), + color: colorScheme.primary, + ), + title: Text(list.name), + subtitle: list.description.isNotEmpty + ? Text( + list.description, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ) + : null, + onTap: () async { + Navigator.pop(context); + await _addToList(context, list); + }, + ); + }, + ), + ), + contentPadding: _isLoading || _lists.isEmpty + ? const EdgeInsets.all(24) + : const EdgeInsets.symmetric(vertical: 8), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + if (widget.listName != null) { + return ElevatedButton.icon( + onPressed: _isLoading + ? null + : () { + if (_existsInList && + _existingItemId != null && + _existingList != null) { + _removeFromList(context, _existingItemId!, _existingList!); + } else { + _createAndAddToList(context, widget.listName!); + } + }, + icon: _isLoading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : Icon( + _existsInList + ? Icons.remove_circle_outline + : _getListIcon(widget.listName!), + ), + label: Text( + _isLoading + ? _existsInList + ? 'Removing...' + : 'Adding...' + : _existsInList + ? 'Remove from ${widget.listName}' + : 'Add to ${widget.listName}', + ), + style: ElevatedButton.styleFrom( + backgroundColor: _existsInList ? colorScheme.errorContainer : null, + foregroundColor: _existsInList ? colorScheme.onErrorContainer : null, + ), + ); + } + + if (widget.useDialog) { + return InkWell( + onTap: () => _showListsDialog(context), + child: widget.child ?? Icon(widget.icon ?? Icons.playlist_add), + ); + } + + return MenuAnchor( + builder: (context, controller, child) { + return widget.child ?? + (widget.label != null + ? ElevatedButton( + onPressed: () { + _loadLists(); + controller.open(); + }, + child: widget.label, + ) + : IconButton( + icon: Icon(widget.icon ?? Icons.playlist_add), + onPressed: () { + _loadLists(); + controller.open(); + }, + )); + }, + menuChildren: [ + if (_isLoading) + const MenuItemButton( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ), + SizedBox(width: 8), + Text('Loading lists...'), + ], + ), + ) + else if (_lists.isEmpty) + MenuItemButton( + onPressed: () => context.push('/library/create'), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.add), + SizedBox(width: 8), + Text('Create new list'), + ], + ), + ) + else + for (final list in _lists) + MenuItemButton( + onPressed: () => _addToList(context, list), + leadingIcon: Icon( + _getListIcon(list.name), + size: 18, + ), + child: Text(list.name), + ), + ], + ); + } + + IconData _getListIcon(String name) { + switch (name.toLowerCase()) { + case 'watchlist': + return Icons.bookmark_outlined; + case 'favourites': + return Icons.favorite; + case 'watch later': + return Icons.watch_later_outlined; + default: + return Icons.folder_outlined; + } + } +} diff --git a/lib/features/library/container/create_list_widget.dart b/lib/features/library/container/create_list_widget.dart new file mode 100644 index 0000000..aa35d8e --- /dev/null +++ b/lib/features/library/container/create_list_widget.dart @@ -0,0 +1,235 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:logging/logging.dart'; + +import '../../pocketbase/service/pocketbase.service.dart'; +import '../service/list_service.dart'; +import '../service/trakt_service.dart'; +import '../types/library_types.dart'; + +class CreateListPage extends StatefulWidget { + const CreateListPage({super.key}); + + @override + State createState() => _CreateListPageState(); +} + +class _CreateListPageState extends State { + final _logger = Logger('CreateListPage'); + final _nameController = TextEditingController(); + final _descriptionController = TextEditingController(); + bool _isLoadingTrakt = false; + List _traktLists = []; + + @override + void initState() { + super.initState(); + + if (TraktService.instance.isAuthenticated) { + _loadTraktLists(); + } + } + + @override + void dispose() { + _nameController.dispose(); + _descriptionController.dispose(); + super.dispose(); + } + + Future _loadTraktLists() async { + try { + setState(() => _isLoadingTrakt = true); + final lists = await TraktService.instance.getLists(); + setState(() { + _traktLists = lists; + _isLoadingTrakt = false; + }); + } catch (e) { + _logger.severe('Error loading Trakt lists', e); + setState(() => _isLoadingTrakt = false); + } + } + + Future _createList() async { + try { + final request = CreateListRequest( + name: _nameController.text, + description: _descriptionController.text, + ); + await ListsService.instance.createList(request); + if (mounted) { + context.pop(true); + } + } catch (e) { + _logger.severe('Error creating list', e); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Scaffold( + backgroundColor: colorScheme.surface, + appBar: AppBar( + title: Text('Create New List', style: theme.textTheme.headlineSmall), + actions: [ + FilledButton( + onPressed: _nameController.text.isEmpty ? null : _createList, + child: const Text('Create'), + ), + const SizedBox(width: 16), + ], + ), + body: DefaultTabController( + length: 2, + child: Column( + children: [ + Container( + color: colorScheme.surface, + child: TabBar( + tabs: const [ + Tab(text: 'Create New'), + Tab(text: 'Import from Trakt'), + ], + labelColor: colorScheme.primary, + unselectedLabelColor: colorScheme.onSurface.withAlpha(150), + indicatorColor: colorScheme.primary, + ), + ), + Expanded( + child: TabBarView( + children: [ + _buildCreateNewTab(colorScheme), + _buildTraktImportTab(colorScheme), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildCreateNewTab(ColorScheme colorScheme) { + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'List Details', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: colorScheme.onSurface, + ), + ), + const SizedBox(height: 24), + TextFormField( + controller: _nameController, + decoration: InputDecoration( + labelText: 'Name', + hintText: 'Enter list name', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + prefixIcon: Icon( + Icons.format_list_bulleted, + color: colorScheme.primary, + ), + ), + onChanged: (value) => setState(() {}), + ), + const SizedBox(height: 16), + TextFormField( + controller: _descriptionController, + decoration: InputDecoration( + labelText: 'Description', + hintText: 'Enter list description', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + prefixIcon: Icon( + Icons.description, + color: colorScheme.primary, + ), + ), + maxLines: 3, + ), + ], + ), + ); + } + + Widget _buildTraktImportTab(ColorScheme colorScheme) { + if (!AppPocketBaseService.instance.pb.authStore.record! + .getStringValue("trakt_token") + .isNotEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.cloud_off, + size: 64, + color: colorScheme.primary.withAlpha(150), + ), + const SizedBox(height: 16), + Text( + 'Trakt Account Not Connected', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: colorScheme.onSurface, + ), + ), + const SizedBox(height: 8), + Text( + 'Connect your Trakt account in settings to import lists', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurface.withAlpha(150), + ), + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: () { + // TODO: Navigate to settings + }, + icon: const Icon(Icons.settings), + label: const Text('Go to Settings'), + ), + ], + ), + ); + } + + return _isLoadingTrakt + ? const Center(child: CircularProgressIndicator()) + : ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 16), + itemCount: _traktLists.length, + itemBuilder: (context, index) { + final TraktList list = _traktLists[index]; + return ListTile( + leading: const CircleAvatar( + child: Icon(Icons.cloud_download), + ), + title: Text(list.name), + subtitle: Text( + '${list.itemCount} items • Updated ${list.lastUpdated}', + ), + trailing: FilledButton( + onPressed: () { + showDialog( + context: context, + builder: (context) => const Dialog( + child: Text("Not implemented"), + ), + ); + }, + child: const Text('Import'), + ), + ); + }, + ); + } +} diff --git a/lib/features/library/containers/connection_list.dart b/lib/features/library/containers/connection_list.dart deleted file mode 100644 index ba369e7..0000000 --- a/lib/features/library/containers/connection_list.dart +++ /dev/null @@ -1,180 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:madari_client/engine/connection.dart'; - -import '../../../engine/engine.dart'; -import '../../../engine/library.dart'; -import '../../settings/types/connection.dart'; - -class ConnectionList extends StatefulWidget { - final bool canDisconnect; - final void Function(Connection item)? onTap; - final bool shrinkWrap; - - const ConnectionList({ - super.key, - this.canDisconnect = false, - this.onTap, - this.shrinkWrap = true, - }); - - @override - State createState() => _ConnectionListState(); -} - -class _ConnectionListState extends State { - @override - void initState() { - super.initState(); - } - - void _refresh() { - setState(() {}); - } - - @override - Widget build(BuildContext context) { - return Consumer( - builder: (BuildContext context, WidgetRef ref, Widget? child) { - final result = ref.watch(getConnectionsProvider); - - return result.when( - data: (data) { - return ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: data.length, - shrinkWrap: widget.shrinkWrap, - itemBuilder: (context, index) { - return ConnectionCard( - onTap: () { - widget.onTap != null ? widget.onTap!(data[index]) : () {}; - }, - connection: data[index], - canDisconnect: widget.canDisconnect, - onRefresh: () { - _refresh(); - ref.refresh(libraryListProvider(1)); - }, - ); - }, - ); - }, - error: (err, o) { - return Text("Something went wrong $err"); - }, - loading: () { - return const Center( - child: CircularProgressIndicator(), - ); - }, - ); - }, - ); - } -} - -class ConnectionCard extends StatefulWidget { - final Connection connection; - final VoidCallback onRefresh; - final bool canDisconnect; - final VoidCallback onTap; - - const ConnectionCard({ - super.key, - required this.connection, - required this.onRefresh, - required this.canDisconnect, - required this.onTap, - }); - - @override - State createState() => _ConnectionCardState(); -} - -class _ConnectionCardState extends State { - bool isLoading = false; - - @override - Widget build(BuildContext context) { - return Consumer( - builder: (BuildContext context, WidgetRef ref, Widget? child) { - return Card( - elevation: 0, - margin: const EdgeInsets.only(bottom: 12), - shape: - RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - child: ListTile( - shape: - RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - onTap: () { - widget.onTap(); - }, - title: Text(widget.connection.title, - style: const TextStyle(fontWeight: FontWeight.bold)), - trailing: widget.canDisconnect - ? TextButton( - onPressed: () => showDialog( - builder: (ctx) { - return AlertDialog( - title: const Text("Confirmation"), - content: SizedBox( - width: MediaQuery.of(context).size.width, - child: Text( - "Are your sure you want to delete ${widget.connection.title}?", - ), - ), - actions: [ - ElevatedButton( - onPressed: () { - Navigator.of(ctx).pop(); - }, - child: const Text("CANCEL"), - ), - FilledButton( - onPressed: () { - _handleConnection(context, ref); - Navigator.of(context).pop(); - }, - child: const Text("DISCONNECT"), - ), - ], - ); - }, - context: context, - ), - child: isLoading - ? const CircularProgressIndicator() - : const Text("Disconnect"), - ) - : null, - ), - ); - }, - ); - } - - void _handleConnection(BuildContext context, WidgetRef ref) async { - if (widget.connection.id == "telegram") { - try { - setState(() { - isLoading = true; - }); - widget.onRefresh(); - } finally { - setState(() { - isLoading = false; - }); - } - - return; - } - - await AppEngine.engine.pb - .collection("connection") - .delete(widget.connection.id); - - widget.onRefresh(); - - ref.refresh(getConnectionsProvider); - } -} diff --git a/lib/features/library/pages/library.page.dart b/lib/features/library/pages/library.page.dart new file mode 100644 index 0000000..d5a8a74 --- /dev/null +++ b/lib/features/library/pages/library.page.dart @@ -0,0 +1,258 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:logging/logging.dart'; +import 'package:madari_client/features/settings/service/selected_profile.dart'; +import 'package:madari_client/features/settings/widget/setting_wrapper.dart'; + +import '../service/list_service.dart'; +import '../service/trakt_service.dart'; +import '../types/library_types.dart'; + +class LibraryPage extends StatefulWidget { + const LibraryPage({super.key}); + + @override + State createState() => _LibraryPageState(); +} + +class _LibraryPageState extends State { + final _logger = Logger('LibraryPage'); + List _lists = []; + bool _isLoading = true; + bool _isRefreshing = false; + + late StreamSubscription _item; + + @override + void initState() { + super.initState(); + _loadLists(); + + _item = + SelectedProfileService.instance.selectedProfileStream.listen((item) { + _loadLists(); + }); + } + + @override + void dispose() { + super.dispose(); + + _item.cancel(); + } + + Future _loadLists() async { + try { + setState(() => _isLoading = true); + final lists = await ListsService.instance.getLists(); + setState(() { + _lists = lists; + _isLoading = false; + }); + } catch (e) { + _logger.severe('Error loading lists', e); + setState(() => _isLoading = false); + } + } + + Future _refreshLists() async { + try { + setState(() => _isRefreshing = true); + final lists = await ListsService.instance.getLists(); + setState(() { + _lists = lists; + _isRefreshing = false; + }); + } catch (e) { + _logger.severe('Error refreshing lists', e); + setState(() => _isRefreshing = false); + } + } + + Future _deleteList(String listId) async { + try { + await ListsService.instance.deleteList(listId); + _loadLists(); + } catch (e) { + _logger.severe('Error deleting list', e); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final isTablet = MediaQuery.of(context).size.width >= 600; + + return Scaffold( + backgroundColor: colorScheme.surface, + appBar: AppBar( + backgroundColor: colorScheme.surface, + title: Text( + 'My Library', + style: theme.textTheme.headlineSmall?.copyWith( + color: colorScheme.onSurface, + ), + ), + actions: [ + IconButton( + icon: const Icon(Icons.add), + onPressed: () async { + await context.push('/library/create'); + + _refreshLists(); + }, + tooltip: 'Create new list', + ), + if (TraktService.instance.isAuthenticated) + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _isRefreshing ? null : _refreshLists, + tooltip: 'Refresh lists', + ), + const SizedBox(width: 8), + ], + ), + body: _isLoading + ? Center( + child: CircularProgressIndicator( + color: colorScheme.primary, + ), + ) + : _lists.isEmpty + ? _buildEmptyState(colorScheme) + : SettingWrapper( + child: _buildListGrid( + isTablet, + colorScheme, + ), + ), + ); + } + + Widget _buildEmptyState(ColorScheme colorScheme) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.folder_outlined, + size: 64, + color: colorScheme.primary.withAlpha(150), + ), + const SizedBox(height: 16), + Text( + 'No Lists Yet', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + color: colorScheme.onSurface, + ), + ), + const SizedBox(height: 8), + Text( + 'Create a list to start organizing your movies and shows', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurface.withAlpha(150), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: () async { + await context.push('/library/create'); + _refreshLists(); + }, + icon: const Icon(Icons.add), + label: const Text('Create New List'), + ), + if (TraktService.instance.isAuthenticated) ...[ + const SizedBox(height: 16), + FilledButton.tonalIcon( + onPressed: () => context.push('/library/create'), + icon: const Icon(Icons.cloud_download), + label: const Text('Import from Trakt'), + ), + ], + ], + ), + ); + } + + Widget _buildListGrid(bool isTablet, ColorScheme colorScheme) { + return RefreshIndicator( + onRefresh: _refreshLists, + child: ListView.builder( + itemCount: _lists.length, + itemBuilder: (context, index) { + final list = _lists[index]; + return _buildListCard(list, colorScheme); + }, + ), + ); + } + + Widget _buildListCard(ListModel list, ColorScheme colorScheme) { + IconData getIconForList() { + if (list.name.toLowerCase() == 'watchlist') { + return Icons.bookmark_outlined; + } + if (list.name.toLowerCase() == 'watch later') { + return Icons.watch_later_outlined; + } + return list.sync ? Icons.sync : Icons.folder_outlined; + } + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Dismissible( + key: Key(list.id), + background: Container( + color: colorScheme.error, + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 16), + child: Icon( + Icons.delete, + color: colorScheme.onError, + ), + ), + direction: DismissDirection.endToStart, + onDismissed: (direction) => _deleteList(list.id), + confirmDismiss: (direction) async { + return await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete List'), + content: + Text('Are you sure you want to delete "${list.name}"?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Delete'), + ), + ], + ), + ); + }, + child: ListTile( + onTap: () => context.push('/library/${list.id}', extra: list), + leading: Icon( + getIconForList(), + color: colorScheme.primary, + size: 20, + ), + title: Text(list.name), + subtitle: + list.description.isNotEmpty ? Text(list.description) : null, + ), + ), + const Divider(height: 1), + ], + ); + } +} diff --git a/lib/features/library/pages/list_detail_page.dart b/lib/features/library/pages/list_detail_page.dart new file mode 100644 index 0000000..18393d7 --- /dev/null +++ b/lib/features/library/pages/list_detail_page.dart @@ -0,0 +1,378 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:logging/logging.dart'; +import 'package:madari_client/features/widgetter/plugins/stremio/utils/size.dart'; + +import '../service/list_service.dart'; +import '../service/trakt_service.dart'; +import '../types/library_types.dart'; + +class ListDetailsPage extends StatefulWidget { + final ListModel list; + + const ListDetailsPage({ + super.key, + required this.list, + }); + + @override + State createState() => _ListDetailsPageState(); +} + +class _ListDetailsPageState extends State { + final _logger = Logger('ListDetailsPage'); + List _items = []; + bool _isLoading = true; + bool _isRefreshing = false; + bool _isGridView = true; + + @override + void initState() { + super.initState(); + _loadItems(); + } + + Future _loadItems() async { + try { + setState(() => _isLoading = true); + final items = await ListsService.instance.getListItems(widget.list.id); + setState(() { + _items = items; + _isLoading = false; + }); + } catch (e) { + _logger.severe('Error loading list items', e); + setState(() => _isLoading = false); + } + } + + Future _refreshItems() async { + if (widget.list.sync) { + try { + setState(() => _isRefreshing = true); + await TraktService.instance.syncList(widget.list.id); + await _loadItems(); + setState(() => _isRefreshing = false); + } catch (e) { + _logger.severe('Error syncing list items', e); + setState(() => _isRefreshing = false); + } + } else { + await _loadItems(); + } + } + + Future _removeItem(ListItemModel item) async { + try { + await ListsService.instance.removeListItem(widget.list.id, item.id); + _loadItems(); + } catch (e) { + _logger.severe('Error removing list item', e); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final isTablet = MediaQuery.of(context).size.width >= 600; + + return Scaffold( + backgroundColor: colorScheme.surface, + body: CustomScrollView( + slivers: [ + _buildAppBar(colorScheme), + if (_isLoading) + const SliverFillRemaining( + child: Center(child: CircularProgressIndicator()), + ) + else if (_items.isEmpty) + SliverFillRemaining( + child: _buildEmptyState(colorScheme), + ) + else + _isGridView + ? _buildGridView(isTablet, colorScheme) + : _buildListView(colorScheme), + ], + ), + ); + } + + Widget _buildAppBar(ColorScheme colorScheme) { + return SliverAppBar.large( + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(widget.list.name), + if (widget.list.description.isNotEmpty) + Text( + widget.list.description, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurface.withAlpha(200), + ), + ), + ], + ), + actions: [ + if (widget.list.sync) + IconButton( + icon: const Icon(Icons.sync), + onPressed: _isRefreshing ? null : _refreshItems, + tooltip: 'Sync with Trakt', + ), + IconButton( + icon: Icon(_isGridView ? Icons.view_list : Icons.grid_view), + onPressed: () => setState(() => _isGridView = !_isGridView), + tooltip: _isGridView ? 'Switch to list view' : 'Switch to grid view', + ), + IconButton( + icon: const Icon(Icons.edit), + onPressed: () => {}, + tooltip: "Edit", + ), + ], + bottom: _isRefreshing + ? PreferredSize( + preferredSize: const Size.fromHeight(2), + child: LinearProgressIndicator( + color: colorScheme.primary, + backgroundColor: colorScheme.surfaceContainerHighest, + ), + ) + : null, + ); + } + + Widget _buildEmptyState(ColorScheme colorScheme) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.movie_outlined, + size: 64, + color: colorScheme.primary.withAlpha(150), + ), + const SizedBox(height: 16), + Text( + 'No Items Yet', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + color: colorScheme.onSurface, + ), + ), + const SizedBox(height: 8), + Text( + 'Add movies and shows from their detail pages', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurface.withAlpha(150), + ), + textAlign: TextAlign.center, + ), + if (widget.list.sync) ...[ + const SizedBox(height: 24), + FilledButton.icon( + onPressed: _refreshItems, + icon: const Icon(Icons.sync), + label: const Text('Sync with Trakt'), + ), + ], + ], + ), + ); + } + + Widget _buildGridView(bool isTablet, ColorScheme colorScheme) { + final result = StremioCardSize.getSize(context, isGrid: true); + + return SliverPadding( + padding: const EdgeInsets.all(16), + sliver: SliverGrid( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: result.columns, + childAspectRatio: 2 / 3, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + delegate: SliverChildBuilderDelegate( + (context, index) => _buildGridItem(_items[index], colorScheme), + childCount: _items.length, + ), + ), + ); + } + + onTap(ListItemModel item) { + context.push( + "/meta/${item.type}/${item.imdbId}?image=${Uri.encodeQueryComponent(item.poster)}", + ); + } + + Widget _buildGridItem(ListItemModel item, ColorScheme colorScheme) { + return Card( + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () { + onTap(item); + }, + child: Stack( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: Image.network( + item.poster, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + color: colorScheme.surfaceContainerHighest, + child: Icon( + item.type == 'movie' ? Icons.movie : Icons.tv, + size: 48, + color: colorScheme.primary, + ), + ); + }, + ), + ), + Padding( + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 4), + Row( + children: [ + Icon( + item.type == 'movie' ? Icons.movie : Icons.tv, + size: 16, + color: colorScheme.onSurface.withAlpha(150), + ), + const SizedBox(width: 4), + Text( + item.type.toUpperCase(), + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith( + color: colorScheme.onSurface.withAlpha(150), + ), + ), + const Spacer(), + const Icon( + Icons.star, + size: 16, + color: Colors.amber, + ), + const SizedBox(width: 4), + Text( + item.rating.toStringAsFixed(1), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ], + ), + ), + ], + ), + Positioned( + top: 8, + right: 8, + child: IconButton( + icon: const Icon(Icons.remove_circle), + style: IconButton.styleFrom( + backgroundColor: colorScheme.surface.withAlpha(200), + foregroundColor: colorScheme.error, + ), + onPressed: () => _removeItem(item), + tooltip: 'Remove from list', + ), + ), + ], + ), + ), + ); + } + + Widget _buildListView(ColorScheme colorScheme) { + return SliverPadding( + padding: const EdgeInsets.symmetric(vertical: 8), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => _buildListItem(_items[index], colorScheme), + childCount: _items.length, + ), + ), + ); + } + + Widget _buildListItem(ListItemModel item, ColorScheme colorScheme) { + return Dismissible( + key: Key(item.id), + background: Container( + color: colorScheme.error, + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 24), + child: Icon( + Icons.delete, + color: colorScheme.onError, + ), + ), + direction: DismissDirection.endToStart, + onDismissed: (direction) => _removeItem(item), + child: ListTile( + leading: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image.network( + item.poster, + width: 40, + height: 60, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 40, + height: 60, + color: colorScheme.surfaceContainerHighest, + child: Icon( + item.type == 'movie' ? Icons.movie : Icons.tv, + color: colorScheme.primary, + ), + ); + }, + ), + ), + title: Text(item.title), + subtitle: Row( + children: [ + Icon( + item.type == 'movie' ? Icons.movie : Icons.tv, + size: 16, + color: colorScheme.onSurface.withAlpha(150), + ), + const SizedBox(width: 4), + Text(item.type.toUpperCase()), + const SizedBox(width: 8), + const Icon( + Icons.star, + size: 16, + color: Colors.amber, + ), + const SizedBox(width: 4), + Text(item.rating.toStringAsFixed(1)), + ], + ), + onTap: () { + onTap(item); + }, + ), + ); + } +} diff --git a/lib/features/library/screen/create_new_library.dart b/lib/features/library/screen/create_new_library.dart deleted file mode 100644 index d72e92a..0000000 --- a/lib/features/library/screen/create_new_library.dart +++ /dev/null @@ -1,293 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:madari_client/engine/engine.dart'; -import 'package:madari_client/features/settings/types/connection.dart'; -import 'package:pocketbase/pocketbase.dart'; - -import '../../../engine/library.dart'; -import '../../connection/containers/folder_selector.dart'; - -class CreateNewLibrary extends StatefulWidget { - final PocketBase engine = AppEngine.engine.pb; - final Connection item; - final VoidCallback onCreated; - final VoidCallback onCreatedAnother; - final ScrollController? scrollController; - - CreateNewLibrary({ - super.key, - required this.item, - required this.onCreated, - required this.onCreatedAnother, - this.scrollController, - }); - - @override - createState() => _CreateNewLibraryState(); -} - -class _CreateNewLibraryState extends State { - final _formKey = GlobalKey(); - String _libraryName = ''; - IconData _selectedIcon = Icons.folder; - final List _selectedTypes = []; - List _folder = []; - - final List _libraryTypes = [ - 'Document', - 'Video', - 'Audio', - 'Photo', - ]; - - final List _availableIcons = [ - // Media Type Icons - Icons.video_library, - Icons.music_note, - Icons.movie, - Icons.library_music, - Icons.photo_library, - Icons.book, - Icons.library_books, - Icons.library_add, - - // Folder and Collection Icons - Icons.folder, - Icons.folder_open, - Icons.create_new_folder, - Icons.collections_bookmark, - Icons.collections, - Icons.local_library, - - // Specific Media Icons - Icons.headphones, - Icons.camera_alt, - Icons.slideshow, - Icons.movie_filter, - Icons.featured_video, - - // Abstract and Conceptual Icons - Icons.category, - Icons.inventory, - Icons.storage, - Icons.my_library_add, - Icons.my_library_books, - Icons.list, - - // Additional Representational Icons - Icons.article, - Icons.topic, - Icons.bookmark, - Icons.label, - Icons.turned_in, - Icons.palette, - - // Device and Storage Icons - Icons.sd_storage, - Icons.cloud, - Icons.cloud_circle, - Icons.device_hub, - - // Miscellaneous - Icons.view_module, - Icons.view_list, - Icons.dashboard, - Icons.grid_view, - Icons.apps, - ]; - - void _saveLibrary() async { - if (_formKey.currentState!.validate()) { - if (_selectedTypes.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - "Select at-least one type", - ), - ), - ); - return; - } - - if (_folder.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - "Folder is required", - ), - ), - ); - return; - } - - _formKey.currentState!.save(); - - try { - await AppEngine.engine.pb.collection("library").create(body: { - "title": _libraryName, - "icon": _selectedIcon.codePoint.toString(), - "types": _selectedTypes.map((item) { - return item.toLowerCase(); - }).toList(), - "user": AppEngine.engine.pb.authStore.record!.id, - "config": _folder.map((item) => item.config ?? item.id).toList(), - "connection": widget.item.id, - }); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Library "$_libraryName" created'), - ), - ); - - widget.onCreated(); - } - } catch (e) { - if (mounted) { - if (e is ClientException) { - final data = e.response["data"] as Map?; - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'Error ${data?.values.first?["message"] ?? e.response["message"]}'), - ), - ); - } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Error ${e.toString()}')), - ); - } - } - } - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text("Create Library"), - leading: GestureDetector( - onTap: () { - Navigator.of(context).pop(); - }, - child: const Icon( - Icons.close, - ), - ), - ), - body: Form( - key: _formKey, - child: ListView( - controller: widget.scrollController, - padding: const EdgeInsets.all(16.0), - children: [ - TextFormField( - textCapitalization: TextCapitalization.sentences, - autofocus: true, - decoration: InputDecoration( - labelText: 'Library Name', - hintText: 'Enter library name', - filled: true, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter a library name'; - } - return null; - }, - onSaved: (value) { - _libraryName = value ?? ""; - }, - ), - const SizedBox(height: 16), - - // Library Icon Selection - Text( - 'Select Library Icon', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox( - height: 4, - ), - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: _availableIcons.map((icon) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 4.0), - child: ChoiceChip( - label: Icon(icon), - visualDensity: VisualDensity.compact, - selected: _selectedIcon == icon, - onSelected: (bool selected) { - setState(() { - _selectedIcon = icon; - }); - }, - ), - ); - }).toList(), - ), - ), - - const SizedBox(height: 16), - - // Library Types Selection - Text( - 'Select Content Types', - style: Theme.of(context).textTheme.titleMedium, - ), - - Wrap( - spacing: 8.0, - children: _libraryTypes.map((type) { - return FilterChip( - label: Text(type), - selected: _selectedTypes.contains(type), - onSelected: (bool selected) { - setState(() { - if (selected) { - _selectedTypes.add(type); - } else { - _selectedTypes.remove(type); - } - }); - }, - ); - }).toList(), - ), - - const SizedBox(height: 16), - - FolderSelector( - item: widget.item, - onFolderSelected: (item) { - setState(() { - _folder = item; - }); - }, - ), - ], - ), - ), - bottomNavigationBar: Padding( - padding: const EdgeInsets.all(8.0), - child: SizedBox( - width: double.infinity, - child: FilledButton( - style: OutlinedButton.styleFrom(), - onPressed: () { - _saveLibrary(); - }, - child: const Text("SAVE"), - ), - ), - ), - ); - } -} diff --git a/lib/features/library/screen/library_screen.dart b/lib/features/library/screen/library_screen.dart deleted file mode 100644 index 6ea8c5f..0000000 --- a/lib/features/library/screen/library_screen.dart +++ /dev/null @@ -1,94 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:madari_client/engine/library.dart'; -import 'package:pocketbase/src/dtos/result_list.dart'; - -import '../../../utils/grid.dart'; -import '../component/libray_card.dart'; - -class LibraryScreen extends StatelessWidget { - final bool minimal; - - const LibraryScreen({ - super.key, - this.minimal = false, - }); - - @override - Widget build(BuildContext context) { - return _buildBody(context); - } - - Widget _buildAppBar() { - return SliverAppBar( - floating: true, - leading: null, - backgroundColor: Colors.black.withOpacity(0.7), - title: const Text( - 'My Libraries', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ), - ); - } - - Widget _buildLibraryGrid( - BuildContext context, - ResultList result, - ) { - final count = getGridResponsiveColumnCount(context); - - return SliverPadding( - padding: EdgeInsets.all(getGridResponsivePadding(context)), - sliver: SliverGrid( - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: count == 3 ? 2 : count, - mainAxisSpacing: getGridResponsiveSpacing(context), - crossAxisSpacing: getGridResponsiveSpacing(context), - childAspectRatio: getGridResponsiveAspectRatio(context), - ), - delegate: SliverChildBuilderDelegate( - (context, index) => LibraryCard( - library: result.items[index], - ), - childCount: result.items.length, - ), - ), - ); - } - - Widget _buildBody(BuildContext context) { - final isDesktop = MediaQuery.of(context).size.width > 800; - - return Consumer(builder: (ctx, ref, child) { - final result = ref.watch(libraryListProvider(1)); - - return result.when( - data: (data) { - if (data.items.isEmpty) { - return const Center( - child: Text("No Libraries Found"), - ); - } - - return CustomScrollView( - slivers: [ - if (!isDesktop) _buildAppBar(), - _buildLibraryGrid(context, result.value!), - ], - ); - }, - error: (c, err) { - return Text("Something went wrong $c"); - }, - loading: () { - return const Center( - child: CircularProgressIndicator(), - ); - }, - ); - }); - } -} diff --git a/lib/features/library/service/list_service.dart b/lib/features/library/service/list_service.dart new file mode 100644 index 0000000..d329790 --- /dev/null +++ b/lib/features/library/service/list_service.dart @@ -0,0 +1,114 @@ +import 'package:logging/logging.dart'; + +import '../../pocketbase/service/pocketbase.service.dart'; +import '../../settings/service/selected_profile.dart'; +import '../types/library_types.dart'; + +class ListsService { + static final ListsService instance = ListsService._internal(); + final _logger = Logger('ListsService'); + + ListsService._internal(); + + Future> getLists() async { + try { + final records = + await AppPocketBaseService.instance.pb.collection('list').getFullList( + filter: + "account_profile = '${SelectedProfileService.instance.selectedProfileId}'", + ); + return records + .map((record) => ListModel.fromJson(record.toJson())) + .toList(); + } catch (e) { + _logger.severe('Error fetching lists', e); + rethrow; + } + } + + Future createList(CreateListRequest request) async { + try { + await AppPocketBaseService.instance.pb.collection('list').create( + body: request.toJson(), + ); + } catch (e) { + _logger.severe('Error creating list', e); + rethrow; + } + } + + Future importTraktList(ListModel traktList) async { + try { + await AppPocketBaseService.instance.pb.collection('list').create( + body: traktList.toJson(), + ); + } catch (e) { + _logger.severe('Error importing Trakt list', e); + rethrow; + } + } + + Future updateList(String id, UpdateListRequest request) async { + try { + await AppPocketBaseService.instance.pb.collection('list').update( + id, + body: request.toJson(), + ); + } catch (e) { + _logger.severe('Error updating list', e); + rethrow; + } + } + + Future deleteList(String id) async { + try { + await AppPocketBaseService.instance.pb.collection('list').delete(id); + } catch (e) { + _logger.severe('Error deleting list', e); + rethrow; + } + } + + Future addListItem(String listId, ListItemModel item) async { + try { + final itemData = item.toJson(); + itemData['list'] = listId; + + await AppPocketBaseService.instance.pb.collection('list_item').create( + body: itemData, + ); + } catch (e) { + _logger.severe('Error adding list item', e); + rethrow; + } + } + + Future> getListItems(String listId) async { + try { + final records = await AppPocketBaseService.instance.pb + .collection('list_item') + .getFullList( + filter: 'list = "$listId"', + sort: '-created', + ); + + return records + .map((record) => ListItemModel.fromJson(record.toJson())) + .toList(); + } catch (e) { + _logger.severe('Error fetching list items', e); + rethrow; + } + } + + Future removeListItem(String listId, String itemId) async { + try { + await AppPocketBaseService.instance.pb + .collection('list_item') + .delete(itemId); + } catch (e) { + _logger.severe('Error removing list item', e); + rethrow; + } + } +} diff --git a/lib/features/library/service/trakt_service.dart b/lib/features/library/service/trakt_service.dart new file mode 100644 index 0000000..e50a687 --- /dev/null +++ b/lib/features/library/service/trakt_service.dart @@ -0,0 +1,237 @@ +import 'dart:convert'; + +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:http/http.dart' as http; +import 'package:logging/logging.dart'; + +import '../../pocketbase/service/pocketbase.service.dart'; +import '../types/library_types.dart'; +import 'list_service.dart'; + +final _logger = Logger('TraktService'); + +class TraktService { + static final TraktService instance = TraktService._internal(); + + static const _baseUrl = 'https://api.trakt.tv'; + static String get _traktClient { + final client = "" ?? DotEnv().get("trakt_client_id"); + + if (client == "") { + _logger.warning('Using default Trakt client ID'); + return "b47864365ac88ecc253c3b0bdf1c82a619c1833e8806f702895a7e8cb06b536a"; + } + + return client; + } + + TraktService._internal(); + + String? get _traktToken => AppPocketBaseService.instance.pb.authStore.record + ?.getStringValue("trakt_token"); + + bool get isAuthenticated => _traktToken?.isNotEmpty ?? false; + + Future> getLists() async { + try { + if (!isAuthenticated) { + throw Exception('Trakt not authenticated'); + } + + final listsResponse = await http.get( + Uri.parse('$_baseUrl/users/me/lists'), + headers: _getHeaders(), + ); + + if (listsResponse.statusCode != 200) { + throw Exception('Failed to fetch lists: ${listsResponse.statusCode}'); + } + + final List listsData = json.decode(listsResponse.body); + final List customLists = + listsData.map((list) => TraktList.fromJson(list)).toList(); + + final TraktList watchlist = TraktList( + id: 'watchlist', // Special ID for watchlist + name: 'Watchlist', + description: 'My Watchlist', + itemCount: 0, + privacy: 'private', + displayNumbers: false, + allowComments: false, + ids: {}, + lastUpdated: DateTime.now(), + ); + + final TraktList favorites = TraktList( + id: 'favorites', // Special ID for favorites + name: 'Favorites', + description: 'My Favorites', + itemCount: 0, + privacy: 'private', + displayNumbers: false, + allowComments: false, + + ids: {}, + lastUpdated: DateTime.now(), + ); + + return [ + watchlist, + favorites, + ...customLists, + ]; + } catch (e, stack) { + _logger.severe('Error fetching Trakt lists', e, stack); + rethrow; + } + } + + Future getList(String listId) async { + try { + if (!isAuthenticated) { + throw Exception('Trakt not authenticated'); + } + + final response = await http.get( + Uri.parse('$_baseUrl/users/me/lists/$listId'), + headers: _getHeaders(), + ); + + if (response.statusCode != 200) { + throw Exception('Failed to fetch list: ${response.statusCode}'); + } + + final data = json.decode(response.body); + return TraktList.fromJson(data); + } catch (e) { + _logger.severe('Error fetching Trakt list', e); + rethrow; + } + } + + Future> getListItems(String listId) async { + try { + if (!isAuthenticated) { + throw Exception('Trakt not authenticated'); + } + + final response = await http.get( + Uri.parse('$_baseUrl/users/me/lists/$listId/items'), + headers: _getHeaders(), + ); + + if (response.statusCode != 200) { + throw Exception('Failed to fetch list items: ${response.statusCode}'); + } + + final List data = json.decode(response.body); + return data.map((item) => TraktListItem.fromJson(item)).toList(); + } catch (e) { + _logger.severe('Error fetching Trakt list items', e); + rethrow; + } + } + + Future syncList(String listId) async { + try { + if (!isAuthenticated) { + throw Exception('Trakt not authenticated'); + } + + final items = await getListItems(listId); + + for (final item in items) { + await ListsService.instance.addListItem( + listId, + ListItemModel( + id: '', + type: item.type, + imdbId: item.ids['imdb'] ?? '', + ids: item.ids, + title: item.title, + description: item.overview ?? '', + poster: '', // You'll need to fetch this from TMDB or similar + rating: item.rating ?? 0.0, + ), + ); + } + } catch (e) { + _logger.severe('Error syncing Trakt list', e); + rethrow; + } + } + + Map _getHeaders() { + return { + 'Content-Type': 'application/json', + 'trakt-api-version': '2', + 'trakt-api-key': _traktClient, + 'Authorization': 'Bearer $_traktToken', + }; + } +} + +class TraktList { + final String id; + final String name; + final String? description; + final String privacy; + final bool displayNumbers; + final bool allowComments; + final int itemCount; + final DateTime lastUpdated; + final Map ids; + + TraktList({ + required this.id, + required this.name, + this.description, + required this.privacy, + required this.displayNumbers, + required this.allowComments, + required this.itemCount, + required this.lastUpdated, + required this.ids, + }); + + factory TraktList.fromJson(Map json) { + return TraktList( + id: json['ids']['trakt'].toString(), + name: json['name'], + description: json['description'], + privacy: json['privacy'], + displayNumbers: json['display_numbers'], + allowComments: json['allow_comments'], + itemCount: json['item_count'], + lastUpdated: DateTime.parse(json['updated_at']), + ids: Map.from(json['ids']), + ); + } +} + +class TraktListItem { + final String type; + final String title; + final String? overview; + final double? rating; + final Map ids; + + TraktListItem({ + required this.type, + required this.title, + this.overview, + this.rating, + required this.ids, + }); + + factory TraktListItem.fromJson(Map json) { + return TraktListItem( + type: json['type'], + title: json[json['type']]['title'], + overview: json[json['type']]['overview'], + rating: json[json['type']]['rating']?.toDouble(), + ids: json[json['type']]['ids'], + ); + } +} diff --git a/lib/features/library/types/library_item.dart b/lib/features/library/types/library_item.dart deleted file mode 100644 index 8b13789..0000000 --- a/lib/features/library/types/library_item.dart +++ /dev/null @@ -1 +0,0 @@ - diff --git a/lib/features/library/types/library_types.dart b/lib/features/library/types/library_types.dart new file mode 100644 index 0000000..53efb89 --- /dev/null +++ b/lib/features/library/types/library_types.dart @@ -0,0 +1,148 @@ +import 'dart:convert'; + +import 'package:madari_client/features/settings/service/selected_profile.dart'; + +import '../../pocketbase/service/pocketbase.service.dart'; + +class ListModel { + final String id; + final String name; + final String description; + final int order; + final bool sync; + final String? traktListId; + + ListModel({ + required this.id, + required this.name, + required this.description, + required this.order, + required this.sync, + this.traktListId, + }); + + factory ListModel.fromJson(Map json) { + return ListModel( + id: json['id'], + name: json['name'], + description: json['description'], + order: json['order'], + sync: json['sync'], + traktListId: json['trakt_list_id'], + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'description': description, + 'order': order, + 'sync': sync, + 'trakt_list_id': traktListId, + }; + } + + ListModel copyWith({ + String? name, + String? description, + int? order, + bool? sync, + String? traktListId, + }) { + return ListModel( + id: id, + name: name ?? this.name, + description: description ?? this.description, + order: order ?? this.order, + sync: sync ?? this.sync, + traktListId: traktListId ?? this.traktListId, + ); + } +} + +class ListItemModel { + final String id; + final String type; + final String imdbId; + final Map ids; + final String title; + final String description; + final String poster; + final double rating; + + ListItemModel({ + required this.id, + required this.type, + required this.imdbId, + required this.ids, + required this.title, + required this.description, + required this.poster, + required this.rating, + }); + + factory ListItemModel.fromJson(Map json) { + return ListItemModel( + id: json['id'], + type: json['type'], + imdbId: json['imdb_id'], + ids: json['ids'] is String ? jsonDecode(json['ids']) : json['ids'], + title: json['title'], + description: json['description'], + poster: json['poster'], + rating: (json['rating'] as num).toDouble(), + ); + } + + Map toJson() { + return { + 'id': id, + 'type': type, + 'imdb_id': imdbId, + 'ids': ids is String ? ids : jsonEncode(ids), + 'title': title, + 'description': description, + 'poster': poster, + 'rating': rating, + }; + } +} + +class CreateListRequest { + final String name; + final String description; + + CreateListRequest({ + required this.name, + required this.description, + }); + + Map toJson() { + return { + 'name': name, + 'description': description, + 'order': 0, + 'sync': false, + 'user': AppPocketBaseService.instance.pb.authStore.record!.id, + 'account_profile': SelectedProfileService.instance.selectedProfileId, + }; + } +} + +class UpdateListRequest { + final String name; + final String description; + + UpdateListRequest({ + required this.name, + required this.description, + }); + + Map toJson() { + return { + 'name': name, + 'description': description, + }; + } +} diff --git a/lib/features/library_item/container/item_list.dart b/lib/features/library_item/container/item_list.dart deleted file mode 100644 index 10d4eea..0000000 --- a/lib/features/library_item/container/item_list.dart +++ /dev/null @@ -1,528 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:intl/intl.dart'; -import 'package:madari_client/engine/library.dart'; -import 'package:madari_client/features/connection/services/base_connection_service.dart'; -import 'package:madari_client/features/connection/services/stremio_service.dart'; -import 'package:madari_client/features/connection/types/stremio.dart'; -import 'package:madari_client/features/library_item/container/stremio_item_card.dart'; -import 'package:madari_client/features/library_item/container/stremio_item_list.dart'; -import 'package:shimmer/shimmer.dart'; - -import '../../../utils/grid.dart'; -import '../../library/component/library_search.dart'; -import 'item_viewer.dart'; - -class ItemList extends ConsumerStatefulWidget { - final LibraryRecord library; - - const ItemList({ - super.key, - required this.library, - }); - - @override - createState() => _ItemListState(); -} - -class _ItemListState extends ConsumerState { - int _currentPage = 1; - final ScrollController _scrollController = ScrollController(); - List _items = []; - bool _isLoading = false; - bool _hasMoreItems = true; - bool _hasInitiallyLoaded = false; - late bool _isGridView; - - bool get isStremio { - return widget.library.connectionType == "stremio_addons"; - } - - late BaseConnectionService _service; - - @override - void initState() { - super.initState(); - if (isStremio) { - _isGridView = true; - } else { - _isGridView = false; - } - _scrollController.addListener(_onScroll); - - Future.microtask(() async { - _fetchInitialItems(); - }); - } - - BaseConnectionService? _item; - BaseConnectionService? get service { - if (_item != null) { - return _item; - } - - _item = _service; - - return _item as BaseConnectionService; - } - - Widget _buildItem(LibraryItemList item) { - if (isStremio) { - final parsed = Meta.fromJson( - jsonDecode(item.config!), - ); - - if (_isGridView) { - return StremioItemCard( - heroPrefix: widget.library.id, - item: item, - parsed: parsed, - service: service as StremioService, - ); - } else { - return StremioItemList( - item: item, - parsed: parsed, - service: service as StremioService, - ); - } - } - - Widget image; - - if (item.logo == "" || item.logo == null) { - image = Icon( - Icons.file_copy, - size: _isGridView ? 46 : 32, - ); - } else { - if (item.logo?.startsWith("/9j") == true) { - image = Image.memory( - base64Decode(item.logo!), - fit: BoxFit.cover, - width: 64, - height: 64, - ); - } else if (item.logo?.startsWith("http://") == true || - item.logo?.startsWith("https://") == true) { - image = Image.network( - item.logo!, - fit: BoxFit.cover, - width: 64, - height: 64, - ); - } else { - image = Image.file( - File(item.logo!), - fit: BoxFit.cover, - width: 64, - height: 64, - ); - } - } - - onTap() => Navigator.of(context).push( - MaterialPageRoute( - builder: (context) { - return ItemViewer( - item: item, - library: widget.library, - ); - }, - ), - ); - - return Container( - margin: _isGridView - ? const EdgeInsets.all(8) - : const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - child: _isGridView - ? ClipRRect( - borderRadius: BorderRadius.circular(20), - child: Stack( - children: [ - Card( - elevation: 0, - child: InkWell( - onTap: onTap, - child: Stack( - children: [ - Center( - child: Padding( - padding: const EdgeInsets.only(bottom: 24.0), - child: AspectRatio( - aspectRatio: 16 / 9, - child: image, - ), - ), - ), - Positioned( - bottom: 0, - right: 0, - left: 0, - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Text( - item.title, - style: Theme.of(context).textTheme.titleMedium, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - ), - ], - ), - ), - ), - if (item.history != null) - Positioned( - child: LinearProgressIndicator( - minHeight: 4, - value: item.history!.progress / 100, - ), - ), - ], - ), - ) - : InkWell( - onTap: onTap, - child: ListTile( - leading: item.logo == null - ? SizedBox( - width: 64, - height: 64, - child: Stack( - children: [ - SizedBox( - width: 64, - height: 64, - child: image, - ), - if (item.history != null) - Positioned( - top: 0, - bottom: 0, - left: 0, - right: 0, - child: Center( - child: CircularProgressIndicator( - value: item.history!.progress / 100, - ), - ), - ), - ], - ), - ) - : ClipRRect( - borderRadius: BorderRadius.circular(4), - child: SizedBox( - height: 40, - child: image, - ), - ), - title: Text( - item.title, - maxLines: 1, - ), - subtitle: subtitleBuilder(item), - ), - ), - ); - } - - Widget subtitleBuilder(LibraryItemList item) { - String data = ""; - - if ((item.size ?? 0) > 0) { - data += "${_formatSize(item.size!)}\n"; - } - - if (item.date != null) { - data += "${_formatDate(item.date ?? DateTime.now())}\n"; - } - - if (item.extra != null) { - data += item.extra!; - } - - return Text(data); - } - - String _formatDate(DateTime date) { - final now = DateTime.now(); - - if (DateFormat('yyyy-MM-dd').format(now) == - DateFormat('yyyy-MM-dd').format(date)) { - return 'Today'; - } else if (DateFormat('yyyy-MM-dd') - .format(now.subtract(const Duration(days: 1))) == - date) { - return 'Yesterday'; - } - return DateFormat('MMMM d, yyyy').format(date); - } - - String _formatSize(int size) { - if (size == 0) return ''; - const suffixes = ['B', 'KB', 'MB', 'GB', 'TB']; - var i = 0; - double formattedSize = size.toDouble(); - while (formattedSize >= 1024 && i < suffixes.length - 1) { - formattedSize /= 1024; - i++; - } - return '${formattedSize.toStringAsFixed(1)} ${suffixes[i]}'; - } - - Widget _buildShimmerItem() { - return ClipRRect( - borderRadius: BorderRadius.circular(10), - child: Shimmer.fromColors( - baseColor: Colors.grey[800]!, - highlightColor: Colors.grey[600]!, - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: _isGridView - ? ClipRRect( - borderRadius: BorderRadius.circular(10), - child: Card( - elevation: 0, - margin: const EdgeInsets.all(8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Image placeholder - AspectRatio( - aspectRatio: isStremio ? 2 / 3 : 16 / 9, - child: Container( - color: Colors.grey[800], - ), - ), - // Title placeholder - if (!isStremio) - Padding( - padding: const EdgeInsets.all(8.0), - child: Container( - height: 16, - width: double.infinity, - color: Colors.grey[800], - ), - ), - ], - ), - ), - ) - : ListTile( - leading: ClipRRect( - borderRadius: BorderRadius.circular(4), - child: Container( - width: 40, - height: 40, - color: Colors.grey[800], - ), - ), - title: Container( - height: 16, - color: Colors.grey[800], - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 8), - Container( - height: 12, - width: 100, - color: Colors.grey[800], - ), - const SizedBox(height: 4), - Container( - height: 12, - width: 150, - color: Colors.grey[800], - ), - ], - ), - ), - ), - ), - ); - } - - void _fetchInitialItems() async { - ref - .read(libraryItemListProvider( - widget.library, - _items, - _currentPage, - null, - ).future) - .then((result) { - if (mounted) { - setState(() { - _items = result.items; - _hasMoreItems = result.items.isNotEmpty; - _hasInitiallyLoaded = true; - }); - } - }); - } - - void _onScroll() { - if (_scrollController.position.pixels >= - _scrollController.position.maxScrollExtent - 200 && - !_isLoading && - _hasMoreItems) { - _loadMoreItems(); - } - } - - Future _loadMoreItems() async { - if (_isLoading) return; - - setState(() { - _isLoading = true; - }); - - try { - _currentPage++; - final result = await ref.read( - libraryItemListProvider( - widget.library, - _items, - _currentPage, - null, - ).future, - ); - - setState(() { - _items.addAll(result.items); - _hasMoreItems = result.items.isNotEmpty; - _isLoading = false; - }); - } catch (err) { - if (mounted) { - setState(() { - _isLoading = false; - }); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Failed to load more items: $err')), - ); - } - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text(widget.library.title), - actions: [ - IconButton( - icon: Icon(_isGridView ? Icons.list : Icons.grid_view), - onPressed: () { - setState(() { - _isGridView = !_isGridView; - }); - }, - ), - IconButton( - onPressed: () async { - final result = await showSearch( - context: context, - delegate: LibraryItemSearchDelegate( - library: widget.library, - items: _items, - ref: ref, - service: service, - ), - ); - - if (result != null && context.mounted) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => ItemViewer( - item: result, - library: widget.library, - ), - ), - ); - } - }, - icon: const Icon(Icons.search), - ), - ], - ), - body: _items.isEmpty && (_isLoading || !_hasInitiallyLoaded) - ? GridView.builder( - padding: const EdgeInsets.all(8), - gridDelegate: isStremio - ? SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: getGridResponsiveColumnCount(context), - mainAxisSpacing: getGridResponsiveSpacing(context), - crossAxisSpacing: getGridResponsiveSpacing(context), - childAspectRatio: 2 / 3, - ) - : const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - childAspectRatio: 1, - crossAxisSpacing: 8, - mainAxisSpacing: 8, - ), - itemCount: 12, - itemBuilder: (context, index) => _buildShimmerItem(), - ) - : _isGridView - ? GridView.builder( - controller: _scrollController, - gridDelegate: isStremio - ? SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: getGridResponsiveColumnCount(context), - mainAxisSpacing: getGridResponsiveSpacing(context), - crossAxisSpacing: getGridResponsiveSpacing(context), - childAspectRatio: 2 / 3, - ) - : SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: getGridResponsiveColumnCount(context), - childAspectRatio: 1, - crossAxisSpacing: isStremio ? 0 : 8, - mainAxisSpacing: isStremio ? 0 : 8, - ), - itemCount: _items.length + (_isLoading ? 1 : 0), - itemBuilder: (context, index) { - if (index < _items.length) { - return _buildItem(_items[index]); - } else { - return _buildShimmerItem(); - } - }, - ) - : Center( - child: Container( - constraints: const BoxConstraints( - maxWidth: 600, - ), - child: ListView.builder( - controller: _scrollController, - itemCount: _items.length + (_isLoading ? 1 : 0), - itemBuilder: (context, index) { - if (index < _items.length) { - return _buildItem(_items[index]); - } else { - return _buildShimmerItem(); - } - }, - ), - ), - ), - ); - } - - @override - void dispose() { - _scrollController.dispose(); - super.dispose(); - } -} diff --git a/lib/features/library_item/container/item_viewer.dart b/lib/features/library_item/container/item_viewer.dart deleted file mode 100644 index c1e855a..0000000 --- a/lib/features/library_item/container/item_viewer.dart +++ /dev/null @@ -1,167 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:madari_client/engine/library.dart'; -import 'package:madari_client/features/doc_viewer/container/doc_viewer.dart'; - -import '../../doc_viewer/types/doc_source.dart'; - -class ItemViewer extends StatefulWidget { - final LibraryRecord library; - final LibraryItemList item; - - const ItemViewer({ - super.key, - required this.library, - required this.item, - }); - - @override - State createState() => _ItemViewerState(); -} - -class _ItemViewerState extends State { - Stream>? source; - - @override - void initState() { - super.initState(); - - Future.microtask(() async { - setState(() { - this.source = source; - }); - }); - } - - @override - Widget build(BuildContext context) { - if (source == null) { - return const Scaffold( - body: Center( - child: CircularProgressIndicator(), - ), - ); - } - - return StreamBuilder( - stream: source, - builder: (context, snapshot) { - if (snapshot.hasError) { - return Scaffold( - body: Text("Something went wrong ${snapshot.error}"), - ); - } - - final hasData = snapshot.data ?? []; - - if (hasData.isEmpty) { - return const Scaffold( - body: Center( - child: CircularProgressIndicator(), - ), - ); - } - - final firstData = snapshot.data!.first; - - if (firstData is ProgressStatus) { - final result = firstData; - - final bool isDarkMode = - Theme.of(context).brightness == Brightness.dark; - final ColorScheme colorScheme = isDarkMode - ? ColorScheme.dark( - primary: Colors.blue.shade300, - surface: Colors.black, - onSurface: Colors.white, - ) - : ColorScheme.light( - primary: Colors.blue.shade600, - surface: Colors.white, - onSurface: Colors.black87, - ); - - return Theme( - data: ThemeData( - colorScheme: colorScheme, - scaffoldBackgroundColor: colorScheme.surface, - ), - child: Scaffold( - appBar: AppBar( - elevation: 0, - backgroundColor: Colors.transparent, - title: Text( - result.title, - style: TextStyle( - color: colorScheme.onSurface, - fontWeight: FontWeight.bold, - ), - ), - centerTitle: true, - ), - body: Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Stack( - alignment: Alignment.center, - children: [ - SizedBox( - width: 200, - height: 200, - child: CircularProgressIndicator( - value: result.percentage, - strokeWidth: 12, - backgroundColor: isDarkMode - ? Colors.grey.shade800 - : Colors.grey.shade300, - valueColor: AlwaysStoppedAnimation( - colorScheme.primary, - ), - ), - ), - Text( - '${result.percentage?.toStringAsFixed(0)}%', - style: TextStyle( - fontSize: 32, - fontWeight: FontWeight.bold, - color: colorScheme.primary, - ), - ), - ], - ), - const SizedBox(height: 24), - Text( - result.progressText ?? 'Downloading...', - style: TextStyle( - fontSize: 16, - color: isDarkMode - ? Colors.grey.shade300 - : Colors.grey.shade700, - ), - textAlign: TextAlign.center, - ), - ], - ), - ), - ), - ), - ); - } - - if (snapshot.connectionState != ConnectionState.done) { - return const Scaffold( - body: Center( - child: CircularProgressIndicator(), - ), - ); - } - - return DocViewer( - source: firstData, - ); - }, - ); - } -} diff --git a/lib/features/library_item/container/stremio_item_card.dart b/lib/features/library_item/container/stremio_item_card.dart deleted file mode 100644 index 0bc8007..0000000 --- a/lib/features/library_item/container/stremio_item_card.dart +++ /dev/null @@ -1,110 +0,0 @@ -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:cached_network_image_platform_interface/cached_network_image_platform_interface.dart'; -import 'package:flutter/material.dart'; -import 'package:madari_client/features/connection/services/stremio_service.dart'; -import 'package:madari_client/features/library_item/container/stremio_item_viewer.dart'; - -import '../../../engine/library.dart'; -import '../../connection/types/stremio.dart'; - -class StremioItemCard extends StatelessWidget { - final LibraryItemList item; - final Meta parsed; - final StremioService service; - final String heroPrefix; - - const StremioItemCard({ - super.key, - required this.item, - required this.parsed, - required this.service, - required this.heroPrefix, - }); - - @override - Widget build(BuildContext context) { - return Card( - margin: const EdgeInsets.all(8), - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: InkWell( - borderRadius: BorderRadius.circular(12), - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) { - return StremioItemViewer( - item: parsed, - service: service, - heroPrefix: heroPrefix, - ); - }, - ), - ); - }, - child: Hero( - tag: "$heroPrefix${parsed.id}", - child: AspectRatio( - aspectRatio: 2 / 3, // Typical poster aspect ratio - child: (parsed.poster == null) - ? Container() - : Container( - decoration: BoxDecoration( - image: DecorationImage( - image: CachedNetworkImageProvider( - parsed.poster!, - imageRenderMethodForWeb: - ImageRenderMethodForWeb.HttpGet, - ), - fit: BoxFit.cover, - ), - ), - child: - parsed.imdbRating != null && parsed.imdbRating != "" - ? Align( - alignment: Alignment.topRight, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Container( - decoration: BoxDecoration( - color: Colors.black54, - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.star, - color: Colors.amber, - size: 16, - ), - const SizedBox(width: 4), - Text( - parsed.imdbRating!, - style: const TextStyle( - color: Colors.white, - fontSize: 12, - ), - ), - ], - ), - ), - ), - ) - : const SizedBox.shrink(), - ), - ), - ), - ), - ), - ); - } -} diff --git a/lib/features/library_item/container/stremio_item_list.dart b/lib/features/library_item/container/stremio_item_list.dart deleted file mode 100644 index 4ea00e9..0000000 --- a/lib/features/library_item/container/stremio_item_list.dart +++ /dev/null @@ -1,130 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:madari_client/engine/library.dart'; -import 'package:madari_client/features/connection/services/stremio_service.dart'; -import 'package:madari_client/features/connection/types/stremio.dart'; -import 'package:madari_client/features/library_item/container/stremio_item_viewer.dart'; - -class StremioItemList extends StatelessWidget { - final LibraryItemList item; - final Meta parsed; - final StremioService service; - - const StremioItemList({ - super.key, - required this.item, - required this.parsed, - required this.service, - }); - - @override - Widget build(BuildContext context) { - return Card( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - child: ClipRRect( - child: InkWell( - borderRadius: BorderRadius.circular(12), - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) { - return StremioItemViewer( - item: parsed, - service: service, - heroPrefix: item.id, - ); - }, - ), - ); - }, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - flex: 1, - child: Hero( - tag: parsed.id, - child: Container( - height: 180, - decoration: BoxDecoration( - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(12), - bottomLeft: Radius.circular(12), - ), - image: parsed.poster == null - ? null - : DecorationImage( - image: NetworkImage(parsed.poster!), - fit: BoxFit.cover, - ), - ), - ), - ), - ), - // Content on the right side - Expanded( - flex: 2, - child: Padding( - padding: const EdgeInsets.all(12.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - parsed.name!, - style: Theme.of(context).textTheme.titleLarge, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (parsed.description != null) const SizedBox(height: 8), - if (parsed.description != null) - Text( - parsed.description!, - maxLines: 3, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyMedium, - ), - const SizedBox(height: 12), - Row( - children: [ - if (parsed.year != null) - Chip( - label: Text( - parsed.year!, - style: const TextStyle(fontSize: 12), - ), - padding: - const EdgeInsets.symmetric(horizontal: 8), - ), - const SizedBox(width: 8), - if (parsed.imdbRating != null && - parsed.imdbRating != "") - Row( - children: [ - const Icon( - Icons.star, - color: Colors.amber, - size: 16, - ), - const SizedBox(width: 4), - Text( - parsed.imdbRating!, - style: Theme.of(context).textTheme.bodySmall, - ), - ], - ), - ], - ), - ], - ), - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/features/library_item/container/stremio_item_season_selector.dart b/lib/features/library_item/container/stremio_item_season_selector.dart deleted file mode 100644 index e378e68..0000000 --- a/lib/features/library_item/container/stremio_item_season_selector.dart +++ /dev/null @@ -1,201 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:intl/intl.dart' as intl; -import 'package:madari_client/features/connection/types/stremio.dart'; - -class StremioItemSeasonSelector extends StatefulWidget { - final Meta meta; - final int? season; - final bool needToPopBeforeSelection; - - const StremioItemSeasonSelector({ - super.key, - required this.meta, - this.season, - this.needToPopBeforeSelection = false, - }); - - @override - State createState() => - _StremioItemSeasonSelectorState(); -} - -class _StremioItemSeasonSelectorState extends State - with SingleTickerProviderStateMixin { - int? selectedSeason; - late TabController _tabController; - late final Map> seasonMap; - - @override - void initState() { - super.initState(); - seasonMap = _organizeEpisodes(); - selectedSeason = widget.season; - - _tabController = TabController( - length: seasonMap.keys.length, - vsync: this, - initialIndex: selectedSeason ?? (seasonMap.keys.first == 0 ? 1 : 0), - ); - } - - @override - void dispose() { - _tabController.dispose(); - super.dispose(); - } - - Map> _organizeEpisodes() { - final episodes = widget.meta.videos ?? []; - return groupBy(episodes, (Video video) => video.season); - } - - @override - Widget build(BuildContext context) { - final seasons = seasonMap.keys.toList()..sort(); - - return CustomScrollView( - slivers: [ - SliverToBoxAdapter( - child: TabBar( - controller: _tabController, - isScrollable: true, - splashBorderRadius: BorderRadius.circular(50), - automaticIndicatorColorAdjustment: true, - dividerColor: Colors.transparent, - tabs: seasons.map((season) { - if (season == 0) { - return const Tab(text: "Specials"); - } - return Tab(text: 'Season $season'); - }).toList(), - ), - ), - const SliverToBoxAdapter( - child: SizedBox(height: 16), - ), - SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - final season = seasons[_tabController.index]; - final episodes = seasonMap[season]!; - if (index >= episodes.length) return null; - - final episode = episodes[index]; - return Padding( - padding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: InkWell( - borderRadius: BorderRadius.circular(12), - onTap: () { - if (widget.needToPopBeforeSelection) { - Navigator.of(context).pop(); - } - }, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Episode number - SizedBox( - width: 30, - child: Text( - '${episode.episode}', - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.grey, - ), - ), - ), - // Thumbnail - if (episode.thumbnail != null && - episode.thumbnail!.isNotEmpty) - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.network( - episode.thumbnail!, - width: 160, - height: 90, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return Container( - width: 160, - height: 90, - color: Colors.grey[800], - ); - }, - ), - ) - else - Container( - width: 160, - height: 90, - decoration: BoxDecoration( - color: Colors.grey[800], - borderRadius: BorderRadius.circular(8), - ), - ), - const SizedBox(width: 16), - // Episode details - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - episode.name ?? 'Episode ${episode.episode}', - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - if (episode.released != null) ...[ - const SizedBox(height: 4), - Text( - intl.DateFormat('MMMM dd yyyy') - .format(episode.released!), - style: TextStyle( - fontSize: 12, - color: Colors.grey[400], - ), - ), - ], - if (episode.overview != null) ...[ - const SizedBox(height: 4), - Text( - episode.overview!, - maxLines: 3, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 14, - color: Colors.grey[300], - ), - ), - ], - ], - ), - ), - ], - ), - ), - ); - }, - childCount: seasonMap[seasons[_tabController.index]]?.length ?? 0, - ), - ), - ], - ); - } -} - -Map> groupBy(Iterable items, T Function(E) key) { - final map = >{}; - - for (final item in items) { - final keyValue = key(item); - if (!map.containsKey(keyValue)) { - map[keyValue] = []; - } - map[keyValue]!.add(item); - } - - return map; -} diff --git a/lib/features/library_item/container/stremio_item_viewer.dart b/lib/features/library_item/container/stremio_item_viewer.dart deleted file mode 100644 index 005113f..0000000 --- a/lib/features/library_item/container/stremio_item_viewer.dart +++ /dev/null @@ -1,467 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:madari_client/features/connection/services/stremio_service.dart'; -import 'package:madari_client/features/connection/types/stremio.dart'; -import 'package:madari_client/features/library_item/container/stremio_stream_selector.dart'; - -import 'stremio_item_season_selector.dart'; - -class StremioItemViewer extends StatefulWidget { - final Meta item; - final StremioService service; - final String heroPrefix; - - const StremioItemViewer({ - super.key, - required this.item, - required this.service, - required this.heroPrefix, - }); - - @override - State createState() => _StremioItemViewerState(); -} - -class _StremioItemViewerState extends State { - bool _isLoading = true; - String? _errorMessage; - - @override - void initState() { - widget.service - .getItemMetaById(widget.item.type, widget.item.id) - .then((itemGet) { - if (mounted) { - setState(() { - _item = itemGet; - _isLoading = false; - }); - } - }).catchError((err) { - setState(() { - _isLoading = false; - _errorMessage = err.toString(); - }); - }); - - super.initState(); - } - - Meta? _item; - - Meta get item { - return _item ?? widget.item; - } - - void _onPlayPressed(BuildContext context) { - if (item.type == "series") { - showModalBottomSheet( - context: context, - builder: (context) { - return Scaffold( - appBar: AppBar( - leading: IconButton( - onPressed: () { - Navigator.of(context).pop(); - }, - icon: const Icon(Icons.close), - ), - title: const Text("Seasons"), - ), - body: StremioItemSeasonSelector( - meta: item, - ), - ); - }, - ); - } else { - showModalBottomSheet( - context: context, - builder: (context) { - return Scaffold( - appBar: AppBar( - leading: IconButton( - onPressed: () { - Navigator.of(context).pop(); - }, - icon: const Icon(Icons.close), - ), - title: const Text("Streams"), - ), - body: StremioStreamSelector( - stremio: widget.service, - item: item, - id: item.id, - ), - ); - }, - ); - } - } - - @override - Widget build(BuildContext context) { - final screenWidth = MediaQuery.of(context).size.width; - final isWideScreen = screenWidth > 900; - final contentWidth = isWideScreen ? 900.0 : screenWidth; - - if (_errorMessage != null) { - return Text("Failed $_errorMessage"); - } - - return Scaffold( - body: CustomScrollView( - slivers: [ - SliverAppBar( - expandedHeight: isWideScreen ? 600 : 500, - pinned: true, - // Add bottom widget to keep controls visible - bottom: PreferredSize( - preferredSize: const Size.fromHeight(40), - child: Container( - width: double.infinity, - color: Colors.black, - padding: EdgeInsets.symmetric( - horizontal: - isWideScreen ? (screenWidth - contentWidth) / 2 : 16, - vertical: 16, - ), - child: Row( - children: [ - Expanded( - child: Text( - item.name!, - style: Theme.of(context).textTheme.titleLarge, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 16), - ElevatedButton.icon( - icon: _isLoading - ? Container( - margin: const EdgeInsets.only(right: 6), - child: const SizedBox( - width: 12, - height: 12, - child: CircularProgressIndicator(), - ), - ) - : const Icon( - Icons.play_arrow_rounded, - size: 24, - color: Colors.black87, - ), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.white, - ), - onPressed: () { - if (item.type == "series" && _isLoading) { - return; - } - - _onPlayPressed(context); - }, - label: Text( - "Play", - style: Theme.of(context) - .primaryTextTheme - .bodyMedium - ?.copyWith( - color: Colors.black87, - ), - ), - ), - ], - ), - ), - ), - flexibleSpace: FlexibleSpaceBar( - background: Stack( - fit: StackFit.expand, - children: [ - if (item.background != null) - Image.network( - item.background!, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - if (item.poster == null) { - return Container(); - } - return Image.network(item.poster!, fit: BoxFit.cover); - }, - ), - // Gradient overlay - DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.transparent, - Colors.black.withOpacity(0.8), - ], - ), - ), - ), - Positioned( - bottom: 86, // Adjusted to account for bottom widget - left: 16, - right: 16, - child: Container( - padding: EdgeInsets.symmetric( - horizontal: isWideScreen - ? (screenWidth - contentWidth) / 2 - : 16, - vertical: 16, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Hero( - tag: "${widget.heroPrefix}${item.id}", - child: Container( - width: 150, - height: 225, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - image: item.poster == null - ? null - : DecorationImage( - image: NetworkImage(item.poster!), - fit: BoxFit.cover, - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.3), - spreadRadius: 2, - blurRadius: 8, - ), - ], - ), - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - if (item.year != null) - Chip( - label: Text(item.year!), - backgroundColor: Colors.white24, - labelStyle: const TextStyle( - color: Colors.white), - ), - const SizedBox(width: 8), - if (item.imdbRating != null) - Row( - children: [ - const Icon( - Icons.star, - color: Colors.amber, - size: 20, - ), - const SizedBox(width: 4), - Text( - item.imdbRating!, - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith(color: Colors.white), - ), - ], - ), - ], - ), - ], - ), - ), - ], - ), - ), - ), - ], - ), - ), - ), - SliverPadding( - padding: EdgeInsets.symmetric( - horizontal: isWideScreen ? (screenWidth - contentWidth) / 2 : 16, - vertical: 16, - ), - sliver: SliverList( - delegate: SliverChildListDelegate([ - const SizedBox( - height: 12, - ), - // Description - Text( - 'Description', - style: Theme.of(context).textTheme.titleLarge, - ), - if (item.description != null) const SizedBox(height: 8), - if (item.description != null) - Text( - item.description!, - style: Theme.of(context).textTheme.bodyMedium, - ), - const SizedBox(height: 16), - - // Additional Details - _buildDetailSection(context, 'Additional Information', [ - if (item.genre != null) - _buildDetailRow('Genres', item.genre!.join(', ')), - if (item.country != null) - _buildDetailRow('Country', item.country!), - if (item.runtime != null) - _buildDetailRow('Runtime', item.runtime!), - if (item.language != null) - _buildDetailRow('Language', item.language!), - ]), - - // Cast - if (item.creditsCast != null && item.creditsCast!.isNotEmpty) - _buildCastSection(context, item.creditsCast!), - - // Trailers - if (item.trailerStreams != null && - item.trailerStreams!.isNotEmpty) - _buildTrailersSection(context, item.trailerStreams!), - ]), - ), - ), - ], - ), - ); - } - - Widget _buildDetailSection( - BuildContext context, String title, List details) { - if (details.isEmpty) return const SizedBox.shrink(); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 8), - ...details, - const SizedBox(height: 16), - ], - ); - } - - Widget _buildDetailRow(String label, String value) { - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 120, - child: Text( - '$label:', - style: const TextStyle(fontWeight: FontWeight.bold), - ), - ), - Expanded(child: Text(value)), - ], - ), - ); - } - - Widget _buildCastSection(BuildContext context, List cast) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Cast', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 8), - SizedBox( - height: 150, - child: ListView.builder( - scrollDirection: Axis.horizontal, - itemCount: cast.length, - itemBuilder: (context, index) { - final actor = cast[index]; - return Padding( - padding: const EdgeInsets.only(right: 16), - child: Column( - children: [ - CircleAvatar( - radius: 50, - backgroundImage: actor.profilePath != null - ? NetworkImage(actor.profilePath!) - : null, - child: actor.profilePath == null - ? Icon(Icons.person, - size: 50, color: Colors.grey[300]) - : null, - ), - const SizedBox(height: 8), - Text( - actor.name, - style: Theme.of(context).textTheme.bodyMedium, - ), - Text( - actor.character, - style: Theme.of(context).textTheme.bodySmall, - ), - ], - ), - ); - }, - ), - ), - const SizedBox(height: 16), - ], - ); - } - - Widget _buildTrailersSection( - BuildContext context, List trailers) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Trailers', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 8), - SizedBox( - height: 100, - child: ListView.builder( - scrollDirection: Axis.horizontal, - itemCount: trailers.length, - itemBuilder: (context, index) { - final trailer = trailers[index]; - return Padding( - padding: const EdgeInsets.only(right: 16), - child: Container( - width: 160, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - color: Colors.black26, - ), - child: Center( - child: Text( - trailer.title, - textAlign: TextAlign.center, - style: const TextStyle(color: Colors.white), - ), - ), - ), - ); - }, - ), - ), - ], - ); - } -} diff --git a/lib/features/library_item/container/stremio_stream_selector.dart b/lib/features/library_item/container/stremio_stream_selector.dart deleted file mode 100644 index 948544f..0000000 --- a/lib/features/library_item/container/stremio_stream_selector.dart +++ /dev/null @@ -1,313 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:background_downloader/background_downloader.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:madari_client/features/connection/types/stremio.dart'; -import 'package:madari_client/features/doc_viewer/container/doc_viewer.dart'; -import 'package:madari_client/features/doc_viewer/types/doc_source.dart'; -import 'package:pocketbase/pocketbase.dart'; - -import '../../connection/services/stremio_service.dart'; -import '../../downloads/service/service.dart'; - -class StremioStreamSelector extends StatefulWidget { - final StremioService stremio; - final Meta item; - final String? episode; - final String? season; - final String id; - final Widget? library; - - const StremioStreamSelector({ - super.key, - required this.id, - required this.stremio, - required this.item, - this.episode, - this.season, - this.library, - }); - - @override - State createState() => _StremioStreamSelectorState(); -} - -class _StremioStreamSelectorState extends State { - late final Stream> _stream; - final Map _downloadStatus = {}; - final Map _downloadProgress = {}; - DownloadService? _downloadService; - StreamSubscription? _downloadSubscription; - - @override - void initState() { - super.initState(); - - if (!kIsWeb) _downloadService = DownloadService.instance; - - _stream = widget.stremio.getStreams( - widget.item.type, - widget.id, - episode: widget.episode, - season: widget.season, - ); - - _setupDownloadListener(); - _checkExistingDownloads(); - } - - @override - void dispose() { - _downloadSubscription?.cancel(); - super.dispose(); - } - - void _setupDownloadListener() { - _downloadSubscription = _downloadService?.updates.listen((update) { - if (!mounted) return; - - switch (update) { - case TaskStatusUpdate(): - final index = int.tryParse(update.task.taskId.split('_').last); - if (index != null) { - setState(() { - _downloadStatus[index] = update.status; - }); - } - - case TaskProgressUpdate(): - final index = int.tryParse(update.task.taskId.split('_').last); - if (index != null) { - setState(() { - _downloadProgress[index] = update.progress; - }); - } - } - }); - } - - Future _checkExistingDownloads() async { - final downloads = await _downloadService?.getAllDownloads(); - if (!mounted) return; - - setState(() { - for (var record in (downloads ?? [])) { - final index = int.tryParse(record.task.taskId.split('_').last); - if (index != null) { - _downloadStatus[index] = record.status; - _downloadProgress[index] = record.progress; - } - } - }); - } - - String _getFileName(VideoStream item) { - return item.behaviorHints?["filename"] ?? "${widget.item.name}.mp4"; - } - - Future _startDownload(VideoStream item, int index) async { - final fileName = _getFileName(item); - final url = item.url; - - if (url == null) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('No URL available for download')), - ); - } - return; - } - - final task = DownloadTask( - taskId: 'download_$index', - url: url, - displayName: fileName.split("/").last, - filename: fileName.split("/").last, - baseDirectory: BaseDirectory.applicationDocuments, - updates: Updates.statusAndProgress, - ); - - try { - await _downloadService?.startDownload(task); - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Download failed: $e')), - ); - } - } - } - - Widget _buildDownloadButton(VideoStream item, int index) { - if (isWeb) { - return const SizedBox( - width: 1, - height: 1, - ); - } - - final status = _downloadStatus[index]; - final progress = _downloadProgress[index] ?? 0.0; - - switch (status) { - case TaskStatus.complete: - return IconButton( - icon: const Icon(Icons.play_circle, color: Colors.green), - onPressed: () => _playDownloadedFile(item), - ); - - case TaskStatus.running: - return Stack( - alignment: Alignment.center, - children: [ - CircularProgressIndicator(value: progress), - IconButton( - icon: const Icon(Icons.pause), - onPressed: () => _downloadService?.pauseDownload( - DownloadTask( - taskId: 'download_$index', - url: item.url!, - filename: _getFileName(item), - ), - ), - ), - ], - ); - - case TaskStatus.paused: - return IconButton( - icon: const Icon(Icons.play_arrow), - onPressed: () => _downloadService?.resumeDownload( - DownloadTask( - taskId: 'download_$index', - url: item.url!, - filename: _getFileName(item), - ), - ), - ); - - default: - return IconButton( - icon: const Icon(Icons.download), - onPressed: () => _startDownload(item, index), - ); - } - } - - Future _playDownloadedFile(VideoStream item) async {} - - @override - Widget build(BuildContext context) { - return StreamBuilder( - stream: _stream, - builder: (context, snapshot) { - if (snapshot.hasError) { - return Text("Something went wrong: ${snapshot.error}"); - } - - if (!snapshot.hasData && - snapshot.connectionState == ConnectionState.done) { - return const Center( - child: Text("No streams available"), - ); - } - - if (!snapshot.hasData) { - return const Center( - child: CircularProgressIndicator(), - ); - } - - if (snapshot.data?.isEmpty == true) { - return const Center( - child: Text("No streams available"), - ); - } - - return ListView.builder( - itemCount: snapshot.data!.length, - itemBuilder: (ctx, index) { - final item = snapshot.data![index]; - - return ListTile( - onTap: () { - DocSource? source; - - if ((item.behaviorHints)?.containsKey("iframe") == true) { - final url = - (item.behaviorHints!["iframe"] as String).replaceAll( - "{imdb}", - widget.item.imdbId!, - ); - - source = IframeSource( - url: url, - title: widget.item.name!, - id: widget.item.id, - season: widget.item.currentVideo?.season.toString() ?? - widget.season, - episode: widget.item.currentVideo?.episode.toString() ?? - widget.episode, - ); - } - - if (item.infoHash != null) { - source = TorrentSource( - id: widget.item.id, - title: widget.item.name!, - infoHash: item.infoHash!, - fileName: - "${item.behaviorHints?["filename"] as String}.mp4", - season: widget.item.currentVideo?.season.toString() ?? - widget.season, - episode: widget.item.currentVideo?.episode.toString() ?? - widget.episode, - ); - } - - if (item.url != null) { - source = URLSource( - title: "${utf8.decode( - widget.item.name!.runes.toList(), - )}.mp4", - url: item.url!, - id: widget.item.id, - fileName: "${_getFileName(item)}.mp4", - season: widget.item.currentVideo?.season.toString() ?? - widget.season, - episode: widget.item.currentVideo?.episode.toString() ?? - widget.episode, - ); - } - - if (source == null) { - return; - } - - Navigator.of(context).push( - MaterialPageRoute(builder: (ctx) { - return DocViewer( - source: source!, - ); - }), - ); - }, - enabled: item.behaviorHints?["filename"] != null, - leading: const Icon(Icons.stream), - title: Text( - utf8.decode((item.name ?? item.title ?? "").runes.toList()), - ), - subtitle: Text( - utf8.decode( - (item.description ?? item.title ?? '').runes.toList()), - ), - trailing: _buildDownloadButton(item, index), - ); - }, - ); - }, - ); - } -} diff --git a/lib/data/global_logs.dart b/lib/features/logger/data/global_logs.data.dart similarity index 100% rename from lib/data/global_logs.dart rename to lib/features/logger/data/global_logs.data.dart diff --git a/lib/features/logger/service/logger.service.dart b/lib/features/logger/service/logger.service.dart new file mode 100644 index 0000000..275cdc4 --- /dev/null +++ b/lib/features/logger/service/logger.service.dart @@ -0,0 +1,36 @@ +import 'package:logging/logging.dart'; + +import '../data/global_logs.data.dart'; + +void setupLogger() { + Logger.root.level = Level.INFO; + + Logger.root.onRecord.listen((record) { + final logs = + '${record.level.name.padRight(10)}${record.loggerName.padRight(30)}${record.time.hour}:${record.time.minute}:${record.time.second}:${record.time.millisecond}: ${record.message}'; + + print(logs); + + globalLogs.add(logs); + if (globalLogs.length > 1000) { + globalLogs.removeAt(0); + } + + if (record.error != null) { + final error = 'Error: ${record.time} ${record.error}'; + print(error); + globalLogs.add(error); + if (globalLogs.length > 1000) { + globalLogs.removeAt(0); + } + } + if (record.stackTrace != null) { + final error = 'StackTrace: ${record.stackTrace}'; + print(error); + globalLogs.add(error); + if (globalLogs.length > 1000) { + globalLogs.removeAt(0); + } + } + }); +} diff --git a/lib/utils/sse_stream_stub.dart b/lib/features/meta/pages/meta_page.dart similarity index 100% rename from lib/utils/sse_stream_stub.dart rename to lib/features/meta/pages/meta_page.dart diff --git a/lib/features/offline_ratings/models/rating_model.dart b/lib/features/offline_ratings/models/rating_model.dart new file mode 100644 index 0000000..394463a --- /dev/null +++ b/lib/features/offline_ratings/models/rating_model.dart @@ -0,0 +1,19 @@ +class RatingModel { + final String tconst; + final double averageRating; + final int numVotes; + + RatingModel({ + required this.tconst, + required this.averageRating, + required this.numVotes, + }); + + factory RatingModel.fromTsv(List columns) { + return RatingModel( + tconst: columns[0], + averageRating: double.parse(columns[1]), + numVotes: int.parse(columns[2]), + ); + } +} diff --git a/lib/features/offline_ratings/pages/offline_ratings.dart b/lib/features/offline_ratings/pages/offline_ratings.dart new file mode 100644 index 0000000..6503112 --- /dev/null +++ b/lib/features/offline_ratings/pages/offline_ratings.dart @@ -0,0 +1,279 @@ +import 'package:flutter/material.dart'; + +import '../models/rating_model.dart' as rating_model; +import '../services/ratings_service.dart'; + +class OfflineRatings extends StatefulWidget { + const OfflineRatings({super.key}); + + @override + State createState() => _OfflineRatingsState(); +} + +class _OfflineRatingsState extends State { + final _urlController = TextEditingController(); + final _ratingsService = RatingsService(); + + List? _ratings; + double _downloadProgress = 0; + String? _error; + bool _isLoading = false; + bool _isDownloadComplete = false; + + Future _downloadRatings(String url) async { + if (url.isEmpty) { + setState(() { + _error = 'Please enter a valid URL'; + _showSnackBar('Please enter a valid URL', isError: true); + }); + return; + } + + setState(() { + _isLoading = true; + _error = null; + _ratings = null; + _isDownloadComplete = false; + }); + + try { + final ratings = await _ratingsService.downloadAndParseRatings( + url, + (progress) { + setState(() { + _downloadProgress = progress; + }); + }, + ); + + setState(() { + _ratings = ratings; + _isLoading = false; + _isDownloadComplete = true; + }); + + _showSnackBar('Download completed successfully!'); + } catch (e) { + setState(() { + _error = e.toString(); + _isLoading = false; + }); + _showSnackBar(e.toString(), isError: true); + } + } + + void _showSnackBar(String message, {bool isError = false}) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: isError ? Colors.red : Colors.green, + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ); + } + + Widget _buildStatusIndicator(ThemeData theme) { + if (_ratings == null) { + return const SizedBox.shrink(); + } + + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: _error != null + ? theme.colorScheme.errorContainer + : _isDownloadComplete + ? theme.colorScheme.primaryContainer + : theme.colorScheme.surfaceVariant, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + _error != null + ? Icons.error_outline + : _isDownloadComplete + ? Icons.check_circle + : Icons.info_outline, + color: _error != null + ? theme.colorScheme.error + : _isDownloadComplete + ? theme.colorScheme.primary + : theme.colorScheme.primary, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + _error ?? + (_isDownloadComplete ? 'Download Complete!' : 'Status'), + style: theme.textTheme.titleMedium?.copyWith( + color: _error != null + ? theme.colorScheme.error + : theme.colorScheme.onPrimaryContainer, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + if (_ratings != null) + Padding( + padding: const EdgeInsets.only( + top: 8.0, + ), + child: Text( + "Ratings found ${_ratings!.length.toString()}", + ), + ), + if (_isLoading) ...[ + const SizedBox(height: 16), + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: LinearProgressIndicator( + value: _downloadProgress, + backgroundColor: theme.colorScheme.surface, + valueColor: AlwaysStoppedAnimation( + theme.colorScheme.primary, + ), + minHeight: 8, + ), + ), + const SizedBox(height: 8), + Text( + 'Downloading: ${(_downloadProgress * 100).toStringAsFixed(1)}%', + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.onPrimaryContainer, + ), + ), + ], + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar( + title: const Text("Ratings Downloader"), + backgroundColor: theme.colorScheme.primary, + foregroundColor: theme.colorScheme.onPrimary, + elevation: 2, + ), + body: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + borderRadius: + const BorderRadius.vertical(bottom: Radius.circular(24)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Download Ratings", + style: theme.textTheme.headlineSmall?.copyWith( + color: theme.colorScheme.onPrimaryContainer, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + "Download and view title ratings offline. Data will be stored locally for quick access.", + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onPrimaryContainer, + ), + ), + ], + ), + ), + Card( + margin: const EdgeInsets.all(16), + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _urlController, + decoration: InputDecoration( + labelText: 'Enter TSV.GZ URL', + hintText: 'https://example.com/title.ratings.tsv.gz', + prefixIcon: const Icon(Icons.link), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + filled: true, + fillColor: theme.colorScheme.surface, + ), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _isLoading + ? null + : () => _downloadRatings(_urlController.text), + icon: Icon(_isLoading + ? Icons.hourglass_empty + : Icons.download), + label: Text( + _isLoading ? 'Downloading...' : 'Start Download', + style: const TextStyle(fontSize: 16), + ), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ], + ), + ), + ), + _buildStatusIndicator(theme), + ], + ), + ), + ); + } +} diff --git a/lib/features/offline_ratings/services/ratings_service.dart b/lib/features/offline_ratings/services/ratings_service.dart new file mode 100644 index 0000000..2ca4000 --- /dev/null +++ b/lib/features/offline_ratings/services/ratings_service.dart @@ -0,0 +1,162 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:archive/archive.dart'; +import 'package:drift/drift.dart'; +import 'package:http/http.dart' as http; +import 'package:path/path.dart' as path; +import 'package:path_provider/path_provider.dart'; + +import '../../../data/db.dart'; +import '../models/rating_model.dart'; + +class RatingsService { + final db = AppDatabase(); + + Future> downloadAndParseRatings( + String url, + void Function(double) onProgress, + ) async { + List ratings = []; + File? tempGzFile; + File? tempTsvFile; + + try { + final tempDir = await getTemporaryDirectory(); + final timestamp = DateTime.now().millisecondsSinceEpoch; + + tempGzFile = File(path.join(tempDir.path, 'ratings_$timestamp.tsv.gz')); + tempTsvFile = File(path.join(tempDir.path, 'ratings_$timestamp.tsv')); + + await _downloadFile(url, tempGzFile, onProgress); + + onProgress(0.95); + + await _extractGzFile(tempGzFile, tempTsvFile); + + onProgress(0.98); + + ratings = await _parseTsvFile(tempTsvFile); + + onProgress(1.0); + + await AppDatabase().ratingTable.deleteAll(); + + await AppDatabase().ratingTable.insertAll( + ratings.map( + (rating) { + return RatingTableData( + tconst: rating.tconst, + averageRating: rating.averageRating, + numVotes: rating.numVotes, + ); + }, + ), + ); + + return ratings; + } catch (e) { + if (e is TimeoutException) { + throw Exception( + 'Download timed out. Please check your connection and try again.'); + } else if (e is FormatException) { + throw Exception( + 'Failed to parse the downloaded data. The file may be corrupted.'); + } else { + throw Exception('Failed to process ratings: ${e.toString()}'); + } + } finally { + // Cleanup temporary files + await _cleanupTempFiles([tempGzFile, tempTsvFile]); + } + } + + Future _downloadFile( + String url, File destFile, Function(double) onProgress) async { + final client = http.Client(); + + try { + final response = await client + .send( + http.Request('GET', Uri.parse(url)), + ) + .timeout( + const Duration(seconds: 120), + ); + + if (response.statusCode != 200) { + throw Exception('Failed to download: HTTP ${response.statusCode}'); + } + + final contentLength = response.contentLength ?? 0; + int received = 0; + + final sink = destFile.openWrite(); + + try { + await for (final chunk in response.stream) { + sink.add(chunk); + received += chunk.length; + + if (contentLength > 0) { + onProgress( + 0.9 * received / contentLength, + ); + } + } + } finally { + await sink.flush(); + await sink.close(); + } + } finally { + client.close(); + } + } + + Future _extractGzFile(File gzFile, File outputFile) async { + final bytes = await gzFile.readAsBytes(); + const gzip = GZipDecoder(); + final decoded = gzip.decodeBytes(bytes); + + if (decoded.isEmpty) { + throw Exception('Decompression failed: Empty result'); + } + + await outputFile.writeAsBytes(decoded); + } + + Future> _parseTsvFile(File tsvFile) async { + final lines = await tsvFile.readAsLines(); + + if (lines.isNotEmpty) { + lines.removeAt(0); + } + + return lines + .where((line) => line.trim().isNotEmpty) + .map((line) { + final columns = line.split('\t'); + try { + return RatingModel.fromTsv(columns); + } catch (e) { + print('Warning: Failed to parse line: $line'); + return null; + } + }) + .where((rating) => rating != null) + .cast() + .toList(); + } + + Future _cleanupTempFiles(List files) async { + for (final file in files) { + if (file != null && await file.exists()) { + try { + await file.delete(); + } catch (e) { + print('Warning: Failed to delete temporary file: ${file.path}'); + } + } + } + } +} diff --git a/lib/features/playlist/service/playlist_service.dart b/lib/features/playlist/service/playlist_service.dart deleted file mode 100644 index 9b45675..0000000 --- a/lib/features/playlist/service/playlist_service.dart +++ /dev/null @@ -1,146 +0,0 @@ -import 'package:pocketbase/pocketbase.dart'; - -import '../../../engine/engine.dart'; -import '../types/playlist.dart'; -import '../types/playlist_item.dart'; - -class PlaylistServiceException implements Exception { - final String message; - final dynamic originalError; - - PlaylistServiceException(this.message, [this.originalError]); - - @override - String toString() => 'PlaylistServiceException: $message'; -} - -class PlaylistService { - static const int _itemsPerPage = 20; - late final PocketBase _pb; - - static PlaylistService? _instance; - PlaylistService._(); - - static void initialize() { - _instance ??= PlaylistService._(); - _instance!._pb = AppEngine.engine.pb; - } - - PlaylistService(this._pb); - - static PlaylistService get instance { - if (_instance == null) { - throw PlaylistServiceException( - 'PlaylistService not initialized. Call PlaylistService.initialize() first.', - ); - } - return _instance!; - } - - /// Creates a new playlist - Future createPlaylist(String name) async { - try { - final record = await _pb.collection('playlist').create(body: { - 'name': name, - 'user': _pb.authStore.record!.id, - }); - - return Playlist.fromJson(record.toJson()); - } catch (e) { - throw PlaylistServiceException('Failed to create playlist', e); - } - } - - /// Gets items from a playlist with pagination - Future> getItems(String playlistId, {int page = 1}) async { - try { - final result = await _pb.collection('playlist_item').getList( - page: page, - perPage: _itemsPerPage, - filter: 'playlist = "$playlistId"', - sort: '-created', - ); - - return result.items - .map((record) => PlaylistItem.fromJson(record.toJson())) - .toList(); - } catch (e) { - throw PlaylistServiceException('Failed to fetch playlist items', e); - } - } - - /// Adds an item to a playlist - Future addToPlaylist( - String playlistId, - ) async { - try { - // Verify if the item already exists in the playlist - final existing = await _pb.collection('playlist_item').getList( - filter: 'playlist = "$playlistId"', - page: 1, - perPage: 1, - ); - - if (existing.items.isNotEmpty) { - throw PlaylistServiceException('Item already exists in playlist'); - } - - final record = await _pb.collection('playlist_item').create(body: { - 'playlist': playlistId, - }); - - return PlaylistItem.fromJson(record.toJson()); - } catch (e) { - if (e is PlaylistServiceException) rethrow; - throw PlaylistServiceException('Failed to add item to playlist', e); - } - } - - /// Removes an item from a playlist - Future removeFromPlaylist(String itemId) async { - try { - await _pb.collection('playlist_item').delete(itemId); - } catch (e) { - throw PlaylistServiceException('Failed to remove item from playlist', e); - } - } - - /// Gets all playlists for a user - Future> getUserPlaylists({int page = 1}) async { - try { - final result = await _pb.collection('playlist').getList( - page: page, - perPage: _itemsPerPage, - filter: 'user = "${_pb.authStore.record!.id}"', - sort: '-created', - ); - - return result.items - .map((record) => Playlist.fromJson(record.toJson())) - .toList(); - } catch (e) { - throw PlaylistServiceException('Failed to fetch user playlists', e); - } - } - - /// Deletes a playlist and all its items - Future deletePlaylist(String playlistId) async { - try { - // First, delete all items in the playlist - final items = await _pb.collection('playlist_item').getList( - filter: 'playlist = "$playlistId"', - page: 1, - perPage: 500, // Use a large number to get all items - ); - - for (final item in items.items) { - await _pb.collection('playlist_item').delete(item.id); - } - - // Then delete the playlist itself - await _pb.collection('playlist').delete(playlistId); - } catch (e) { - throw PlaylistServiceException('Failed to delete playlist', e); - } - } -} diff --git a/lib/features/playlist/types/playlist.dart b/lib/features/playlist/types/playlist.dart deleted file mode 100644 index e4280ae..0000000 --- a/lib/features/playlist/types/playlist.dart +++ /dev/null @@ -1,41 +0,0 @@ -class Playlist { - final String id; - final String collectionId; - final String collectionName; - final String name; - final String userId; - final DateTime created; - final DateTime updated; - - Playlist({ - required this.id, - required this.collectionId, - required this.collectionName, - required this.name, - required this.userId, - required this.created, - required this.updated, - }); - - factory Playlist.fromJson(Map json) { - return Playlist( - id: json['id'], - collectionId: json['collectionId'], - collectionName: json['collectionName'], - name: json['name'], - userId: json['user'], - created: DateTime.parse(json['created']), - updated: DateTime.parse(json['updated']), - ); - } - - Map toJson() => { - 'id': id, - 'collectionId': collectionId, - 'collectionName': collectionName, - 'name': name, - 'user': userId, - 'created': created.toIso8601String(), - 'updated': updated.toIso8601String(), - }; -} diff --git a/lib/features/playlist/types/playlist_item.dart b/lib/features/playlist/types/playlist_item.dart deleted file mode 100644 index 0df2e81..0000000 --- a/lib/features/playlist/types/playlist_item.dart +++ /dev/null @@ -1,49 +0,0 @@ -class PlaylistItem { - final String id; - final String collectionId; - final String collectionName; - final String playlistId; - final String libraryId; - final Map item; - final String itemId; - final DateTime created; - final DateTime updated; - - PlaylistItem({ - required this.id, - required this.collectionId, - required this.collectionName, - required this.playlistId, - required this.libraryId, - required this.item, - required this.itemId, - required this.created, - required this.updated, - }); - - factory PlaylistItem.fromJson(Map json) { - return PlaylistItem( - id: json['id'], - collectionId: json['collectionId'], - collectionName: json['collectionName'], - playlistId: json['playlist'], - libraryId: json['library'], - item: json['item'], - itemId: json['item_id'], - created: DateTime.parse(json['created']), - updated: DateTime.parse(json['updated']), - ); - } - - Map toJson() => { - 'id': id, - 'collectionId': collectionId, - 'collectionName': collectionName, - 'playlist': playlistId, - 'library': libraryId, - 'item': item, - 'item_id': itemId, - 'created': created.toIso8601String(), - 'updated': updated.toIso8601String(), - }; -} diff --git a/lib/features/pocketbase/service/pocketbase.service.dart b/lib/features/pocketbase/service/pocketbase.service.dart new file mode 100644 index 0000000..903aa98 --- /dev/null +++ b/lib/features/pocketbase/service/pocketbase.service.dart @@ -0,0 +1,41 @@ +import 'package:flutter/foundation.dart'; +import 'package:pocketbase/pocketbase.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class AppPocketBaseService { + AppPocketBaseService._(); + + static AppPocketBaseService? _instance; + + late final PocketBase pb; + + static AppPocketBaseService get instance { + if (_instance == null) { + throw StateError( + 'AppPocketBaseService not initialized. Call ensureInitialized() first.'); + } + return _instance!; + } + + static Future ensureInitialized() async { + if (_instance != null) return; + + final prefs = await SharedPreferences.getInstance(); + + final store = AsyncAuthStore( + save: (String data) async => prefs.setString('pb_auth', data), + initial: prefs.getString('pb_auth'), + clear: prefs.clear, + ); + + _instance = AppPocketBaseService._(); + await _instance!._initialize(store); + } + + Future _initialize(AuthStore authStore) async { + pb = PocketBase( + kDebugMode ? 'http://100.64.0.1:8090' : 'https://api-v2.madari.media', + authStore: authStore, + ); + } +} diff --git a/lib/features/search/pages/search_page.dart b/lib/features/search/pages/search_page.dart new file mode 100644 index 0000000..613744d --- /dev/null +++ b/lib/features/search/pages/search_page.dart @@ -0,0 +1 @@ +// TODO Implement this library. diff --git a/lib/features/settings/model/external_media_player.dart b/lib/features/settings/model/external_media_player.dart new file mode 100644 index 0000000..af5f5a7 --- /dev/null +++ b/lib/features/settings/model/external_media_player.dart @@ -0,0 +1,9 @@ +class ExternalMediaPlayer { + final String id; + final String name; + + const ExternalMediaPlayer({ + required this.id, + required this.name, + }); +} diff --git a/lib/features/settings/model/playback_settings_model.dart b/lib/features/settings/model/playback_settings_model.dart new file mode 100644 index 0000000..7b81981 --- /dev/null +++ b/lib/features/settings/model/playback_settings_model.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; + +class PlaybackSettings { + bool autoPlay; + double playbackSpeed; + String defaultAudioTrack; + bool disableHardwareAcceleration; + bool disableSubtitles; + String defaultSubtitleTrack; + Color subtitleColor; + double fontSize; + bool externalPlayer; + String? selectedExternalPlayer; + + PlaybackSettings({ + this.autoPlay = true, + this.playbackSpeed = 1.0, + this.defaultAudioTrack = 'eng', + this.disableHardwareAcceleration = false, + this.disableSubtitles = false, + this.defaultSubtitleTrack = 'eng', + this.subtitleColor = Colors.white, + this.fontSize = 16, + this.externalPlayer = false, + this.selectedExternalPlayer, + }); + + Map toJson() => { + 'autoPlay': autoPlay, + 'playbackSpeed': playbackSpeed, + 'defaultAudioTrack': defaultAudioTrack, + 'defaultSubtitleTrack': defaultSubtitleTrack, + 'subtitleColor': subtitleColor.value, + 'fontSize': fontSize, + }; + + factory PlaybackSettings.fromJson(Map json) { + return PlaybackSettings( + autoPlay: json['autoPlay'] ?? true, + playbackSpeed: json['playbackSpeed'] ?? 1.0, + defaultAudioTrack: json['defaultAudioTrack'] ?? 'eng', + defaultSubtitleTrack: json['defaultSubtitleTrack'] ?? 'eng', + subtitleColor: Color(json['subtitleColor'] ?? Colors.white.value), + fontSize: json['fontSize'] ?? 16, + ); + } +} diff --git a/lib/features/settings/navigation/account_navigation.dart b/lib/features/settings/navigation/account_navigation.dart deleted file mode 100644 index debcc41..0000000 --- a/lib/features/settings/navigation/account_navigation.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../screen/email_settings_screen.dart'; -import '../screen/help_screen.dart'; -import '../screen/notification_screen.dart'; -import '../screen/payment_screen.dart'; -import '../screen/profile_setting.dart'; -import '../screen/security_screen.dart'; - -class AccountNavigation { - static void navigateToProfile(BuildContext context) { - Navigator.push( - context, - MaterialPageRoute(builder: (context) => const ProfileScreen()), - ); - } - - static void navigateToEmailSettings(BuildContext context) { - Navigator.push( - context, - MaterialPageRoute(builder: (context) => const EmailSettingsScreen()), - ); - } - - static void navigateToSecurity(BuildContext context) { - Navigator.push( - context, - MaterialPageRoute(builder: (context) => const SecurityScreen()), - ); - } - - static void navigateToNotifications(BuildContext context) { - Navigator.push( - context, - MaterialPageRoute(builder: (context) => const NotificationsScreen()), - ); - } - - static void navigateToPayments(BuildContext context) { - Navigator.push( - context, - MaterialPageRoute(builder: (context) => const PaymentScreen()), - ); - } - - static void navigateToHelp(BuildContext context) { - Navigator.push( - context, - MaterialPageRoute(builder: (context) => const HelpScreen()), - ); - } -} diff --git a/lib/features/settings/pages/appearance_page.dart b/lib/features/settings/pages/appearance_page.dart new file mode 100644 index 0000000..6c8000e --- /dev/null +++ b/lib/features/settings/pages/appearance_page.dart @@ -0,0 +1,211 @@ +import 'package:flutter/material.dart'; + +import '../../theme/theme/app_theme.dart'; + +class AppearancePage extends StatefulWidget { + const AppearancePage({super.key}); + + @override + State createState() => _AppearancePageState(); +} + +class _AppearancePageState extends State { + final List _presetColors = [ + Colors.red, + Colors.pink, + Colors.purple, + Colors.deepPurple, + Colors.indigo, + Colors.blue, + Colors.lightBlue, + Colors.cyan, + Colors.teal, + Colors.green, + Colors.lightGreen, + Colors.lime, + Colors.yellow, + Colors.amber, + Colors.orange, + Colors.deepOrange, + Colors.brown, + Colors.blueGrey, + Colors.grey, + Colors.indigoAccent, + Colors.black, + ]; + + void _updateThemeColor(Color color) { + final appTheme = AppTheme(); + appTheme.setPrimaryColorFromRGB( + color.red, + color.green, + color.blue, + ); + } + + Widget _buildColorButton(Color color, ThemeData theme) { + return Semantics( + button: true, + label: 'Select color', + child: InkWell( + onTap: () => _updateThemeColor(color), + borderRadius: BorderRadius.circular(16), + child: Container( + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: theme.colorScheme.outline.withOpacity(0.5), + width: 2, + ), + ), + child: color == theme.colorScheme.primary + ? Center( + child: Icon( + Icons.check_circle, + color: ThemeData.estimateBrightnessForColor(color) == + Brightness.dark + ? Colors.white + : Colors.black, + ), + ) + : null, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final screenWidth = MediaQuery.of(context).size.width; + final horizontalPadding = screenWidth > 1024 + ? screenWidth * 0.2 + : screenWidth > 600 + ? 48.0 + : 24.0; + + return Scaffold( + appBar: AppBar( + title: Text( + 'Appearance', + style: theme.textTheme.titleLarge, + ), + ), + body: SingleChildScrollView( + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: horizontalPadding, + vertical: 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Container( + height: 80, + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: AppTheme().getCurrentTheme().brightness == + Brightness.light + ? theme.colorScheme.primary + : theme.colorScheme.outline.withOpacity(0.5), + ), + ), + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: () { + if (AppTheme().getCurrentTheme().brightness == + Brightness.dark) { + setState(() => AppTheme().toggleTheme()); + } + }, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.light_mode, + color: theme.colorScheme.onSurface, + ), + const SizedBox(height: 8), + Text( + 'Light', + style: theme.textTheme.bodyLarge, + ), + ], + ), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Container( + height: 80, + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: AppTheme().getCurrentTheme().brightness == + Brightness.dark + ? theme.colorScheme.primary + : theme.colorScheme.outline.withOpacity(0.5), + ), + ), + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: () { + if (AppTheme().getCurrentTheme().brightness == + Brightness.light) { + setState(() => AppTheme().toggleTheme()); + } + }, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.dark_mode, + color: theme.colorScheme.onSurface, + ), + const SizedBox(height: 8), + Text( + 'Dark', + style: theme.textTheme.bodyLarge, + ), + ], + ), + ), + ), + ), + ], + ), + const SizedBox(height: 32), + Text( + 'Accent Color', + style: theme.textTheme.titleLarge, + ), + const SizedBox(height: 16), + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 5, + mainAxisSpacing: 16, + crossAxisSpacing: 16, + childAspectRatio: 1, + ), + itemCount: _presetColors.length, + itemBuilder: (context, index) { + return _buildColorButton(_presetColors[index], theme); + }, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/settings/pages/change_password_page.dart b/lib/features/settings/pages/change_password_page.dart new file mode 100644 index 0000000..0e67fbd --- /dev/null +++ b/lib/features/settings/pages/change_password_page.dart @@ -0,0 +1,276 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:go_router/go_router.dart'; + +import '../../pocketbase/service/pocketbase.service.dart'; + +class ChangePasswordPage extends StatefulWidget { + const ChangePasswordPage({super.key}); + + @override + State createState() => _ChangePasswordPageState(); +} + +class _ChangePasswordPageState extends State { + final _formKey = GlobalKey(); + final _currentPasswordController = TextEditingController(); + final _newPasswordController = TextEditingController(); + final _confirmPasswordController = TextEditingController(); + final _emailController = TextEditingController(); + bool _isLoading = false; + bool _obscureCurrentPassword = true; + bool _obscureNewPassword = true; + bool _obscureConfirmPassword = true; + + @override + void initState() { + super.initState(); + _loadUserEmail(); + } + + Future _loadUserEmail() async { + try { + final user = AppPocketBaseService.instance.pb.authStore.record; + if (user != null) { + _emailController.text = user.data['email'] ?? ''; + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to load user data: $e')), + ); + } + } + } + + Future _updatePassword() async { + if (!_formKey.currentState!.validate()) return; + + try { + setState(() => _isLoading = true); + final user = AppPocketBaseService.instance.pb.authStore.record; + + if (user != null) { + await AppPocketBaseService.instance.pb.collection('users').update( + user.id, + body: { + 'oldPassword': _currentPasswordController.text, + 'password': _newPasswordController.text, + 'passwordConfirm': _confirmPasswordController.text, + }, + ); + + final email = _emailController.text; + final newPassword = _newPasswordController.text; + + await AppPocketBaseService.instance.pb + .collection('users') + .authWithPassword( + email, + newPassword, + ); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Password updated successfully')), + ); + } + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to update password: $e')), + ); + } + } finally { + setState(() => _isLoading = false); + } + } + + Widget _buildPasswordField({ + required String label, + required TextEditingController controller, + required bool obscureText, + required VoidCallback onToggleVisibility, + required String? Function(String?) validator, + String? tooltip, + }) { + return Semantics( + textField: true, + label: tooltip ?? label, + child: TextFormField( + controller: controller, + obscureText: obscureText, + style: Theme.of(context).textTheme.bodyLarge, + decoration: InputDecoration( + labelText: label, + prefixIcon: const Icon(Icons.lock_outline), + suffixIcon: IconButton( + tooltip: 'Toggle password visibility', + icon: Icon(obscureText ? Icons.visibility_off : Icons.visibility), + onPressed: onToggleVisibility, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline.withOpacity(0.5), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.primary, + width: 2, + ), + ), + filled: true, + fillColor: + Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3), + hoverColor: Theme.of(context).colorScheme.onSurface.withOpacity(0.08), + focusColor: Theme.of(context).colorScheme.onSurface.withOpacity(0.12), + ), + validator: validator, + onFieldSubmitted: (_) => FocusScope.of(context).nextFocus(), + keyboardType: TextInputType.visiblePassword, + ), + ); + } + + @override + Widget build(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + final horizontalPadding = switch (screenWidth) { + > 1024 => screenWidth * 0.2, + > 600 => 48.0, + _ => 16.0, + }; + + final theme = Theme.of(context); + final isSmallScreen = screenWidth <= 600; + + return Scaffold( + backgroundColor: theme.colorScheme.surface, + appBar: AppBar( + title: const Text('Change Password'), + leading: IconButton( + tooltip: 'Back', + icon: const Icon(Icons.arrow_back), + onPressed: () => context.pop(), + ), + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : Focus( + autofocus: true, + child: Shortcuts( + shortcuts: { + LogicalKeySet(LogicalKeyboardKey.tab): + const NextFocusIntent(), + LogicalKeySet( + LogicalKeyboardKey.shift, LogicalKeyboardKey.tab): + const PreviousFocusIntent(), + }, + child: SingleChildScrollView( + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: horizontalPadding, + vertical: isSmallScreen ? 0.0 : 24.0, + ), + child: Form( + key: _formKey, + child: Card( + child: Padding( + padding: EdgeInsets.all(isSmallScreen ? 16.0 : 24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Change Password', + style: theme.textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + 'Enter your current password and choose a new one.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + SizedBox(height: isSmallScreen ? 16.0 : 24.0), + _buildPasswordField( + label: 'Current Password', + controller: _currentPasswordController, + obscureText: _obscureCurrentPassword, + onToggleVisibility: () => setState(() => + _obscureCurrentPassword = + !_obscureCurrentPassword), + validator: (value) => value?.isEmpty ?? true + ? 'Please enter current password' + : null, + tooltip: 'Enter your current password', + ), + SizedBox(height: isSmallScreen ? 12.0 : 16.0), + _buildPasswordField( + label: 'New Password', + controller: _newPasswordController, + obscureText: _obscureNewPassword, + onToggleVisibility: () => setState(() => + _obscureNewPassword = !_obscureNewPassword), + validator: (value) => value?.isEmpty ?? true + ? 'Please enter new password' + : null, + tooltip: 'Enter your new password', + ), + SizedBox(height: isSmallScreen ? 12.0 : 16.0), + _buildPasswordField( + label: 'Confirm New Password', + controller: _confirmPasswordController, + obscureText: _obscureConfirmPassword, + onToggleVisibility: () => setState(() => + _obscureConfirmPassword = + !_obscureConfirmPassword), + validator: (value) { + if (value?.isEmpty ?? true) { + return 'Please confirm new password'; + } + if (value != _newPasswordController.text) { + return 'Passwords do not match'; + } + return null; + }, + tooltip: 'Confirm your new password', + ), + SizedBox(height: isSmallScreen ? 16.0 : 24.0), + Center( + child: FilledButton.icon( + onPressed: _updatePassword, + icon: const Icon(Icons.lock), + label: const Text('Update Password'), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ), + ); + } + + @override + void dispose() { + _currentPasswordController.dispose(); + _newPasswordController.dispose(); + _confirmPasswordController.dispose(); + _emailController.dispose(); + super.dispose(); + } +} diff --git a/lib/features/settings/pages/connections_page.dart b/lib/features/settings/pages/connections_page.dart new file mode 100644 index 0000000..613744d --- /dev/null +++ b/lib/features/settings/pages/connections_page.dart @@ -0,0 +1 @@ +// TODO Implement this library. diff --git a/lib/features/settings/pages/debug/clear_cache_page.dart b/lib/features/settings/pages/debug/clear_cache_page.dart new file mode 100644 index 0000000..613744d --- /dev/null +++ b/lib/features/settings/pages/debug/clear_cache_page.dart @@ -0,0 +1 @@ +// TODO Implement this library. diff --git a/lib/features/settings/pages/debug/logs_page.dart b/lib/features/settings/pages/debug/logs_page.dart new file mode 100644 index 0000000..810a6b6 --- /dev/null +++ b/lib/features/settings/pages/debug/logs_page.dart @@ -0,0 +1,248 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:go_router/go_router.dart'; +import 'package:madari_client/features/settings/widget/setting_wrapper.dart'; + +import '../../../logger/data/global_logs.data.dart'; + +class LogsPage extends StatefulWidget { + const LogsPage({super.key}); + + @override + State createState() => _LogsPageState(); +} + +class _LogsPageState extends State { + List parsedLogs = []; + final FocusNode _refreshFocusNode = FocusNode(); + final FocusNode _copyFocusNode = FocusNode(); + + @override + void initState() { + super.initState(); + _parseLogs(); + } + + @override + void dispose() { + _refreshFocusNode.dispose(); + _copyFocusNode.dispose(); + super.dispose(); + } + + void _parseLogs() { + parsedLogs = globalLogs.reversed.map((log) => LogEntry.parse(log)).toList(); + } + + void _copyToClipboard() { + Clipboard.setData(ClipboardData(text: globalLogs.join('\n'))); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Logs copied to clipboard'), + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 2), + backgroundColor: Theme.of(context).colorScheme.secondaryContainer, + showCloseIcon: true, + ), + ); + } + } + + Color _getLevelColor(String level) { + switch (level.toUpperCase()) { + case 'ERROR': + return Colors.red; + case 'WARN': + case 'WARNING': + return Colors.orange; + case 'INFO': + return Colors.blue; + case 'DEBUG': + return Colors.grey; + default: + return Colors.grey; + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + final screenWidth = MediaQuery.of(context).size.width; + + return Scaffold( + backgroundColor: colorScheme.surface, + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.pop(), + ), + backgroundColor: colorScheme.surface, + title: Text( + "Application Logs", + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + actions: [ + IconButton( + focusNode: _copyFocusNode, + onPressed: _copyToClipboard, + icon: Icon(Icons.copy, color: colorScheme.primary), + tooltip: 'Copy all logs', + ), + IconButton( + focusNode: _refreshFocusNode, + onPressed: () { + setState(() { + _parseLogs(); + }); + }, + icon: Icon(Icons.refresh, color: colorScheme.primary), + tooltip: 'Refresh logs', + ), + ], + ), + body: Shortcuts( + shortcuts: { + LogicalKeySet(LogicalKeyboardKey.tab): const NextFocusIntent(), + LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.tab): + const PreviousFocusIntent(), + }, + child: SettingWrapper( + child: parsedLogs.isEmpty + ? Center( + child: Text( + 'No logs available', + style: theme.textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurface.withOpacity(0.7), + ), + ), + ) + : ListView.builder( + itemCount: parsedLogs.length, + itemBuilder: (context, index) { + final log = parsedLogs[index]; + return Semantics( + label: 'Log entry: ${log.level} from ${log.service}', + child: Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest + .withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colorScheme.outline.withValues(alpha: 0.5), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: _getLevelColor(log.level) + .withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: _getLevelColor(log.level) + .withValues(alpha: 0.3), + ), + ), + child: Text( + log.level, + style: theme.textTheme.labelSmall?.copyWith( + color: _getLevelColor(log.level), + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 12), + Text( + log.service, + style: theme.textTheme.labelMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 8), + Text( + '•', + style: TextStyle( + color: colorScheme.onSurfaceVariant + .withValues(alpha: 0.5), + ), + ), + const SizedBox(width: 8), + Text( + log.timestamp, + style: theme.textTheme.labelMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + fontFamily: 'monospace', + ), + ), + ], + ), + const SizedBox(height: 8), + SelectableText( + log.message, + style: theme.textTheme.bodyMedium?.copyWith( + fontFamily: 'monospace', + color: colorScheme.onSurface, + ), + ), + ], + ), + ), + ); + }, + ), + ), + ), + ); + } +} + +class LogEntry { + final String level; + final String service; + final String timestamp; + final String message; + + LogEntry({ + required this.level, + required this.service, + required this.timestamp, + required this.message, + }); + + factory LogEntry.parse(String logLine) { + final parts = logLine.split(RegExp(r'\s+')); + if (parts.length >= 3) { + final level = parts[0]; + final service = parts[1]; + final timestamp = parts[2]; + final message = parts.skip(3).join(' '); + return LogEntry( + level: level, + service: service, + timestamp: timestamp, + message: message, + ); + } + return LogEntry( + level: 'UNKNOWN', + service: 'Unknown', + timestamp: '', + message: logLine, + ); + } +} diff --git a/lib/features/settings/pages/full_profile_selector.dart b/lib/features/settings/pages/full_profile_selector.dart new file mode 100644 index 0000000..958b2dd --- /dev/null +++ b/lib/features/settings/pages/full_profile_selector.dart @@ -0,0 +1,333 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:madari_client/features/pocketbase/service/pocketbase.service.dart'; +import 'package:pocketbase/pocketbase.dart'; + +import '../service/selected_profile.dart'; + +class FullProfileSelectorPage extends StatefulWidget { + const FullProfileSelectorPage({super.key}); + + @override + State createState() => + _FullProfileSelectorPageState(); +} + +class _FullProfileSelectorPageState extends State { + final _selectedProfileService = SelectedProfileService.instance; + + late Future> _future; + + late StreamSubscription _listener; + + @override + void initState() { + super.initState(); + + _future = AppPocketBaseService.instance.pb + .collection('account_profile') + .getList(); + + _listener = _selectedProfileService.selectedProfileStream.listen((item) { + if (mounted) { + setState(() { + _future = AppPocketBaseService.instance.pb + .collection('account_profile') + .getList(); + }); + } + }); + } + + @override + void dispose() { + _listener.cancel(); + super.dispose(); + } + + int _getCrossAxisCount(double width) { + if (width > 1200) return 6; + if (width > 900) return 5; + if (width > 600) return 4; + if (width > 400) return 3; + return 2; + } + + double _getAvatarSize(double width) { + if (width > 1200) return 64; + if (width > 900) return 56; + if (width > 600) return 48; + return 40; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + final isDesktop = width > 600; + final padding = isDesktop ? 32.0 : 16.0; + final spacing = isDesktop ? 24.0 : 16.0; + final avatarSize = _getAvatarSize(width); + final crossAxisCount = _getCrossAxisCount(width); + + return Scaffold( + appBar: AppBar( + title: Text( + 'Select Profile', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + centerTitle: isDesktop, + ), + body: Center( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: isDesktop ? 1400 : double.infinity, + ), + child: Column( + children: [ + if (isDesktop) const SizedBox(height: 32), + Expanded( + child: FutureBuilder( + future: _future, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 48, + color: colorScheme.error, + ), + const SizedBox(height: 16), + Text( + 'Error loading profiles', + style: theme.textTheme.titleMedium?.copyWith( + color: colorScheme.error, + ), + ), + const SizedBox(height: 8), + Text( + '${snapshot.error}', + style: theme.textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ], + ), + ); + } + + if (!snapshot.hasData) { + return const Center( + child: CircularProgressIndicator()); + } + + final profiles = snapshot.data!.items; + + if (_selectedProfileService.selectedProfileId == null && + profiles.isNotEmpty) { + _selectedProfileService + .setSelectedProfile(profiles[0].id); + } + + return GridView.builder( + padding: EdgeInsets.all(padding), + gridDelegate: + SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + mainAxisSpacing: spacing, + crossAxisSpacing: spacing, + mainAxisExtent: isDesktop ? 200 : 160, + ), + itemCount: profiles.length, + itemBuilder: (context, index) { + final profile = profiles[index]; + + return StreamBuilder( + stream: + _selectedProfileService.selectedProfileStream, + builder: (context, snapshot) { + final isSelected = snapshot.data == profile.id; + + return InkWell( + onTap: () async { + final currentSelectedId = + _selectedProfileService + .selectedProfileId; + final newSelectedId = + currentSelectedId == profile.id + ? profile.id + : profile.id; + await _selectedProfileService + .setSelectedProfile(newSelectedId); + + if (context.mounted) context.push("/"); + }, + borderRadius: BorderRadius.circular(12), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: isSelected + ? colorScheme.primaryContainer + .withOpacity(0.3) + : Colors.transparent, + border: Border.all( + color: isSelected + ? colorScheme.primary + : colorScheme.outlineVariant, + width: isSelected ? 2 : 1, + ), + ), + child: Column( + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + Stack( + alignment: Alignment.center, + children: [ + if (profile.data['profile_image'] != + null && + profile.data['profile_image'] != + "") + CircleAvatar( + radius: avatarSize, + backgroundImage: NetworkImage( + AppPocketBaseService + .instance.pb.files + .getUrl( + profile, + profile.data[ + 'profile_image'], + ) + .toString(), + ), + ) + else + CircleAvatar( + radius: avatarSize, + backgroundColor: isSelected + ? colorScheme.primary + : colorScheme + .surfaceVariant, + child: Text( + profile.data['name'][0] + .toUpperCase(), + style: TextStyle( + color: isSelected + ? colorScheme.onPrimary + : colorScheme + .onSurfaceVariant, + fontSize: avatarSize * 0.75, + fontWeight: isSelected + ? FontWeight.bold + : FontWeight.normal, + ), + ), + ), + if (isSelected) + Positioned( + right: 0, + bottom: 0, + child: Container( + padding: EdgeInsets.all( + isDesktop ? 6 : 4), + decoration: BoxDecoration( + color: colorScheme.primary, + shape: BoxShape.circle, + border: Border.all( + color: + colorScheme.surface, + width: 2, + ), + ), + child: Icon( + Icons.check, + color: + colorScheme.onPrimary, + size: isDesktop ? 24 : 20, + ), + ), + ), + ], + ), + const SizedBox(height: 16), + Padding( + padding: EdgeInsets.symmetric( + horizontal: isDesktop ? 16 : 8, + ), + child: Text( + profile.data['name'], + style: (isDesktop + ? theme.textTheme.titleLarge + : theme + .textTheme.titleMedium) + ?.copyWith( + color: isSelected + ? colorScheme.primary + : null, + fontWeight: isSelected + ? FontWeight.bold + : null, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ); + }, + ); + }, + ); + }, + ), + ), + Padding( + padding: EdgeInsets.all(padding), + child: SizedBox( + width: isDesktop ? 400 : double.infinity, + child: FilledButton.icon( + onPressed: () { + context.push("/profile/manage"); + }, + style: FilledButton.styleFrom( + minimumSize: Size.fromHeight(isDesktop ? 64 : 56), + ), + icon: Icon( + Icons.manage_accounts, + size: isDesktop ? 28 : 24, + ), + label: Text( + 'Manage Profiles', + style: (isDesktop + ? theme.textTheme.titleLarge + : theme.textTheme.titleMedium) + ?.copyWith( + color: Theme.of(context).colorScheme.onPrimary, + ), + ), + ), + ), + ), + ], + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/features/settings/pages/layout_page.dart b/lib/features/settings/pages/layout_page.dart new file mode 100644 index 0000000..79b9b41 --- /dev/null +++ b/lib/features/settings/pages/layout_page.dart @@ -0,0 +1,544 @@ +import 'package:cached_query/cached_query.dart'; +import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; +import 'package:madari_client/features/home/pages/home_page.dart'; +import 'package:madari_client/features/settings/service/selected_profile.dart'; + +import '../../pocketbase/service/pocketbase.service.dart'; +import '../../widgetter/plugin_base.dart'; +import '../../widgetter/service/home_layout_service.dart'; +import '../../widgetter/types/widget_gallery.dart'; + +final _logger = Logger('LayoutPage'); + +class LayoutPage extends StatefulWidget { + const LayoutPage({super.key}); + + @override + State createState() => _LayoutPageState(); +} + +class _LayoutPageState extends State with TickerProviderStateMixin { + final List widgets = []; + final List layoutWidgets = []; + final GlobalKey _scaffoldKey = + GlobalKey(); + bool isDragging = false; + double dragHeight = 320; + final double _minCellWidth = 150; + int _crossAxisCount = 2; + final ScrollController _scrollController = ScrollController(); + bool _isLoading = false; + + final query = Query( + key: "home_layout", + queryFn: () async { + return await AppPocketBaseService.instance.pb + .collection('home_layout') + .getFullList( + sort: 'order', + filter: + 'profiles = \'${SelectedProfileService.instance.selectedProfileId}\'', + ); + }, + ); + + @override + void initState() { + super.initState(); + _logger.info('Initializing LayoutPage'); + + loadData(); + } + + void _showError(String message) { + _scaffoldKey.currentState?.showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.error_outline, color: Colors.white), + const SizedBox(width: 8), + Expanded(child: Text(message)), + ], + ), + behavior: SnackBarBehavior.floating, + backgroundColor: Colors.red.shade700, + action: SnackBarAction( + label: 'Retry', + textColor: Colors.white, + onPressed: loadData, + ), + ), + ); + } + + void _showSuccess(String message) { + _scaffoldKey.currentState?.showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.check_circle_outline, color: Colors.white), + const SizedBox(width: 8), + Text(message), + ], + ), + behavior: SnackBarBehavior.floating, + backgroundColor: Colors.green.shade700, + duration: const Duration(seconds: 2), + ), + ); + } + + Future loadData() async { + try { + setState(() => _isLoading = true); + final result = PluginRegistry.instance.getAvailablePlugins(); + final presets = await Future.wait( + result.map((item) => item.presets()), + ); + widgets.clear(); + for (var value in presets) { + widgets.addAll(value); + } + + final layoutItems = await HomeLayoutService.instance.loadLayoutWidgets(); + layoutWidgets.clear(); + layoutWidgets.addAll(layoutItems); + + _logger.info( + 'Loaded ${widgets.length} preset widgets and ${layoutWidgets.length} layout widgets', + ); + + setState(() {}); + } catch (e) { + _logger.severe('Error loading data', e); + _showError('Failed to load widgets. Please check your connection.'); + } finally { + setState(() => _isLoading = false); + } + } + + Future _addWidget(PresetWidgetConfig preset) async { + try { + _logger.info('Adding widget ${preset.title}'); + + final userId = AppPocketBaseService.instance.pb.authStore.record!.id; + final newWidget = LayoutWidgetConfig.fromPreset( + preset, + userId, + layoutWidgets.length, + ); + + final success = await HomeLayoutService.instance.saveLayoutWidget( + newWidget, + ); + + query.refetch(); + + if (success) { + await loadData(); + _showSuccess('Added ${preset.title} widget'); + } else { + _showError('Failed to add widget. Please try again.'); + } + } catch (e) { + _logger.severe('Error adding widget', e); + _showError('Failed to add widget. Please check your connection.'); + } + } + + Future _removeWidget(LayoutWidgetConfig widget) async { + try { + _logger.info('Removing widget ${widget.id}'); + final success = + await HomeLayoutService.instance.deleteLayoutWidget(widget.id); + if (success) { + setState(() { + layoutWidgets.remove(widget); + }); + await HomeLayoutService.instance.updateLayoutOrder(layoutWidgets); + query.refetch(); + _showSuccess('Removed widget successfully'); + } else { + _showError('Failed to remove widget. Please try again.'); + } + } catch (e) { + _logger.severe('Error removing widget', e); + _showError('Failed to remove widget. Please check your connection.'); + } + } + + Future _addAllWidgets() async { + try { + _logger.info('Adding all widgets'); + + final userId = AppPocketBaseService.instance.pb.authStore.record!.id; + int successCount = 0; + + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => const Center( + child: CircularProgressIndicator(), + ), + ); + + for (var preset in widgets) { + final newWidget = LayoutWidgetConfig.fromPreset( + preset, + userId, + layoutWidgets.length + successCount, + ); + + final success = + await HomeLayoutService.instance.saveLayoutWidget(newWidget); + + query.refetch(); + if (success) { + successCount++; + } + } + + Navigator.of(context).pop(); + + if (successCount > 0) { + await loadData(); + _showSuccess('Added $successCount widgets successfully'); + } else { + _showError('Failed to add widgets. Please try again.'); + } + } catch (e) { + Navigator.of(context).pop(); + _logger.severe('Error adding all widgets', e); + _showError('Failed to add widgets. Please check your connection.'); + } + } + + Future _reorderWidgets(int oldIndex, int newIndex) async { + try { + _logger.info('Reordering widget from $oldIndex to $newIndex'); + setState(() { + if (newIndex > oldIndex) { + newIndex -= 1; + } + final item = layoutWidgets.removeAt(oldIndex); + layoutWidgets.insert(newIndex, item); + }); + + final success = + await HomeLayoutService.instance.updateLayoutOrder(layoutWidgets); + query.refetch(); + if (!success) { + _showError('Failed to save new order. Please try again.'); + } + } catch (e) { + _logger.severe('Error reordering widgets', e); + _showError('Failed to reorder widgets. Please check your connection.'); + } + } + + Widget _buildListItem(LayoutWidgetConfig widget, int index) { + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + key: ValueKey(widget.id), + child: Dismissible( + key: ValueKey(widget.id), + direction: DismissDirection.endToStart, + background: Container( + color: Colors.red, + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 16), + child: const Icon(Icons.delete, color: Colors.white), + ), + onDismissed: (direction) => _removeWidget(widget), + child: InkWell( + onTap: () {}, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + child: Row( + children: [ + InkWell( + borderRadius: BorderRadius.circular(16), + child: Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: + Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(16), + ), + child: Icon( + Icons.widgets_outlined, + size: 22, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + widget.title, + style: Theme.of(context).textTheme.titleMedium, + ), + if (widget.config.containsKey("description")) + Text( + widget.config['description'], + style: Theme.of(context).textTheme.titleSmall, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + MouseRegion( + cursor: SystemMouseCursors.grab, + child: ReorderableDragStartListener( + index: index, + child: Icon( + Icons.drag_handle, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildWidgetPreview(PresetWidgetConfig widget) { + final preview = Card( + clipBehavior: Clip.antiAlias, + child: AspectRatio( + aspectRatio: 1.5, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withAlpha(200), + Theme.of(context).colorScheme.surface, + ], + ), + ), + child: MouseRegion( + cursor: SystemMouseCursors.grab, + child: Stack( + children: [ + Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.widgets_outlined, + size: 32, + color: Theme.of(context) + .colorScheme + .onSurface + .withAlpha(180), + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + widget.title, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleSmall, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + widget.description, + textAlign: TextAlign.center, + maxLines: 2, + style: Theme.of(context).textTheme.titleSmall, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + + return Tooltip( + message: 'Drag to add ${widget.title}', + child: LongPressDraggable( + data: widget, + delay: const Duration(milliseconds: 150), + feedback: Material( + elevation: 8, + child: SizedBox( + width: _minCellWidth, + child: preview, + ), + ), + child: GestureDetector( + onTap: () => _addWidget(widget), + child: preview, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return ScaffoldMessenger( + key: _scaffoldKey, + child: Scaffold( + appBar: AppBar( + title: const Text('Home Layout'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + tooltip: 'Refresh widgets', + onPressed: loadData, + ), + IconButton( + icon: const Icon(Icons.preview), + tooltip: 'Preview', + onPressed: () { + showModalBottomSheet( + context: context, + builder: (context) => const HomePage(), + ); + }, + ), + ], + ), + body: Column( + children: [ + Expanded( + child: DragTarget( + onAcceptWithDetails: (item) => _addWidget(item.data), + builder: (context, candidateData, rejectedData) { + return layoutWidgets.isEmpty + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.drag_indicator, + size: 48, + color: + theme.colorScheme.onSurface.withAlpha(100), + ), + const SizedBox(height: 16), + Text( + 'Drag widgets here or tap to add them', + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onSurface + .withAlpha(150), + ), + ), + ], + ), + ) + : ReorderableListView.builder( + scrollController: _scrollController, + itemCount: layoutWidgets.length, + itemBuilder: (context, index) => + _buildListItem(layoutWidgets[index], index), + onReorder: _reorderWidgets, + buildDefaultDragHandles: false, + ); + }, + ), + ), + GestureDetector( + onVerticalDragUpdate: _handleDragUpdate, + child: Container( + width: 50, + height: 5, + decoration: BoxDecoration( + color: theme.colorScheme.surfaceVariant, + borderRadius: BorderRadius.circular(2.5), + ), + margin: const EdgeInsets.symmetric(vertical: 8), + ), + ), + Container( + height: dragHeight, + decoration: BoxDecoration( + color: theme.colorScheme.surface, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Text( + 'Available Widgets', + style: theme.textTheme.titleMedium?.copyWith( + color: theme.colorScheme.onSurface.withAlpha(200), + ), + ), + const SizedBox(width: 4), + Text( + '(${widgets.length})', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withAlpha(150), + ), + ), + const Spacer(), + IconButton( + onPressed: () { + _addAllWidgets(); + }, + icon: const Text("Add all to Home"), + ), + ], + ), + ), + Expanded( + child: GridView.builder( + padding: const EdgeInsets.all(16), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: _crossAxisCount, + mainAxisSpacing: 16, + crossAxisSpacing: 16, + childAspectRatio: 1.5, + ), + itemCount: widgets.length, + itemBuilder: (context, index) { + final item = widgets[index]; + return _buildWidgetPreview(item); + }, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + void _handleDragUpdate(DragUpdateDetails details) { + setState(() { + dragHeight = (dragHeight - details.delta.dy).clamp(200.0, 600.0); + }); + } +} diff --git a/lib/features/settings/pages/playback_settings_page.dart b/lib/features/settings/pages/playback_settings_page.dart new file mode 100644 index 0000000..5847d13 --- /dev/null +++ b/lib/features/settings/pages/playback_settings_page.dart @@ -0,0 +1,382 @@ +import 'package:flex_color_picker/flex_color_picker.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../model/playback_settings_model.dart'; +import '../service/external_players.dart'; +import '../service/playback_setting_service.dart'; +import '../widget/searchable_language_dropdown.dart'; +import '../widget/setting_wrapper.dart'; + +class PlaybackSettingsPage extends StatefulWidget { + const PlaybackSettingsPage({super.key}); + + @override + State createState() => _PlaybackSettingsPageState(); +} + +class _PlaybackSettingsPageState extends State { + late Future _settingsFuture; + late Future> _languagesFuture; + + @override + void initState() { + super.initState(); + _settingsFuture = PlaybackSettingsService.instance.getSettings(); + _languagesFuture = PlaybackSettingsService.instance.getLanguages(); + } + + Widget _buildSection(String title, List children) { + return Card( + margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 0), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + ...children, + ], + ), + ), + ); + } + + Widget _buildSpeedSelector(PlaybackSettings settings) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Playback Speed'), + const SizedBox(height: 8), + Row( + children: [ + const Text('0.5x'), + Expanded( + child: Slider( + value: settings.playbackSpeed, + min: 0.5, + max: 5.0, + divisions: 45, + label: '${settings.playbackSpeed.toStringAsFixed(2)}x', + onChanged: (value) { + setState(() { + settings.playbackSpeed = value; + PlaybackSettingsService.instance.saveSettings(settings); + }); + }, + ), + ), + const Text('5.0x'), + ], + ), + ], + ); + } + + Widget _buildSubtitlePreview(PlaybackSettings settings) { + return Container( + height: 120, + decoration: BoxDecoration( + color: Colors.black87, + borderRadius: BorderRadius.circular(8), + ), + child: Stack( + alignment: Alignment.center, + children: [ + Positioned.fill( + child: Center( + child: Text( + 'Preview Subtitle Text\nSecond Line', + textAlign: TextAlign.center, + style: TextStyle( + color: settings.subtitleColor, + fontSize: settings.fontSize, + shadows: [ + Shadow( + color: Colors.black.withOpacity(0.8), + offset: const Offset(1, 1), + blurRadius: 2, + ), + ], + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildLanguageSelector( + PlaybackSettings settings, + Map languages, + String label, + bool isAudio, + ) { + return SearchableLanguageDropdown( + languages: languages, + value: + isAudio ? settings.defaultAudioTrack : settings.defaultSubtitleTrack, + label: label, + onChanged: (value) { + setState(() { + if (isAudio) { + settings.defaultAudioTrack = value; + } else { + settings.defaultSubtitleTrack = value; + } + PlaybackSettingsService.instance.saveSettings(settings); + }); + }, + ); + } + + Widget _buildExternalPlayerSelector(PlaybackSettings settings) { + final platform = defaultTargetPlatform; + String currentPlatform; + + switch (platform) { + case TargetPlatform.android: + currentPlatform = 'android'; + break; + case TargetPlatform.iOS: + currentPlatform = 'ios'; + break; + case TargetPlatform.macOS: + currentPlatform = 'macos'; + break; + default: + return const SizedBox.shrink(); + } + + final players = externalPlayers[currentPlatform] ?? []; + + return DropdownButtonFormField( + value: settings.selectedExternalPlayer, + decoration: const InputDecoration( + labelText: 'External Player', + border: OutlineInputBorder(), + contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + items: players + .map((player) => DropdownMenuItem( + value: player.id, + child: Text(player.name), + )) + .toList(), + onChanged: settings.externalPlayer + ? (value) { + if (value != null) { + setState(() { + settings.selectedExternalPlayer = value; + PlaybackSettingsService.instance.saveSettings(settings); + }); + } + } + : null, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Playback Settings'), + centerTitle: true, + ), + body: SettingWrapper( + child: FutureBuilder( + future: Future.wait([_settingsFuture, _languagesFuture]), + builder: (context, AsyncSnapshot> snapshot) { + if (!snapshot.hasData) { + return const Center(child: CircularProgressIndicator()); + } + + final settings = snapshot.data![0] as PlaybackSettings; + final languages = snapshot.data![1] as Map; + + return ListView( + children: [ + _buildSection( + 'General', + [ + SwitchListTile( + title: const Text('Auto Play'), + subtitle: const Text('Automatically play next episode'), + value: settings.autoPlay, + onChanged: (value) { + setState(() { + settings.autoPlay = value; + PlaybackSettingsService.instance + .saveSettings(settings); + }); + }, + ), + const SizedBox(height: 16), + _buildSpeedSelector(settings), + ], + ), + _buildSection( + 'Audio', + [ + _buildLanguageSelector( + settings, + languages, + 'Default Audio Track', + true, + ), + const SizedBox(height: 16), + SwitchListTile( + title: const Text('Disable Hardware Acceleration'), + subtitle: const Text('May help with audio sync issues'), + value: settings.disableHardwareAcceleration, + onChanged: (value) { + setState(() { + settings.disableHardwareAcceleration = value; + PlaybackSettingsService.instance + .saveSettings(settings); + }); + }, + ), + ], + ), + _buildSection( + 'Subtitles', + [ + SwitchListTile( + title: const Text('Enable Subtitles'), + value: !settings.disableSubtitles, + onChanged: (value) { + setState(() { + settings.disableSubtitles = !value; + PlaybackSettingsService.instance + .saveSettings(settings); + }); + }, + ), + const SizedBox(height: 16), + if (!settings.disableSubtitles) ...[ + _buildLanguageSelector( + settings, + languages, + 'Default Subtitle Track', + false, + ), + const SizedBox(height: 16), + ListTile( + title: const Text('Subtitle Color'), + trailing: Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: settings.subtitleColor, + border: Border.all(color: Colors.grey), + borderRadius: BorderRadius.circular(4), + ), + ), + onTap: () async { + final color = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Pick a color'), + content: SingleChildScrollView( + child: ColorPicker( + color: settings.subtitleColor, + onColorChanged: (color) { + settings.subtitleColor = color; + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop( + context, settings.subtitleColor), + child: const Text('Select'), + ), + ], + ), + ); + if (color != null) { + setState(() { + settings.subtitleColor = color; + PlaybackSettingsService.instance + .saveSettings(settings); + }); + } + }, + ), + const SizedBox(height: 16), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Font Size'), + Row( + children: [ + const Text('11'), + Expanded( + child: Slider( + value: settings.fontSize, + min: 11, + max: 60, + divisions: 49, + label: settings.fontSize.round().toString(), + onChanged: (value) { + setState(() { + settings.fontSize = value; + PlaybackSettingsService.instance + .saveSettings(settings); + }); + }, + ), + ), + const Text('60'), + ], + ), + ], + ), + const SizedBox(height: 16), + _buildSubtitlePreview(settings), + ], + ], + ), + _buildSection( + 'External Player', + [ + SwitchListTile( + title: const Text('Use External Player'), + subtitle: + const Text('Open videos in your preferred player'), + value: settings.externalPlayer, + onChanged: (value) { + setState(() { + settings.externalPlayer = value; + PlaybackSettingsService.instance + .saveSettings(settings); + }); + }, + ), + if (settings.externalPlayer) ...[ + const SizedBox(height: 16), + _buildExternalPlayerSelector(settings), + ], + ], + ), + ], + ); + }, + ), + ), + ); + } +} diff --git a/lib/features/settings/pages/profile_page.dart b/lib/features/settings/pages/profile_page.dart new file mode 100644 index 0000000..8659de6 --- /dev/null +++ b/lib/features/settings/pages/profile_page.dart @@ -0,0 +1,341 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:go_router/go_router.dart'; +import 'package:http/http.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:madari_client/features/settings/service/selected_profile.dart'; + +import '../../pocketbase/service/pocketbase.service.dart'; +import '../widget/language_selector.dart'; +import '../widget/region_selector.dart'; + +class ProfilePage extends StatefulWidget { + const ProfilePage({super.key}); + + @override + State createState() => _ProfilePageState(); +} + +class _ProfilePageState extends State { + final _formKey = GlobalKey(); + final _fullNameController = TextEditingController(); + final _emailController = TextEditingController(); + File? _selectedImage; + String? _selectedRegion; + bool _isLoading = false; + String? _selectedLanguage; + + @override + void initState() { + super.initState(); + _loadUserProfile(); + } + + Future _loadUserProfile() async { + try { + setState(() => _isLoading = true); + final user = AppPocketBaseService.instance.pb.authStore.record; + if (user != null) { + _fullNameController.text = user.data['name'] ?? ''; + _emailController.text = user.data['email'] ?? ''; + _selectedRegion = user.data['region']; + _selectedLanguage = user.data['language']; + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to load profile: $e')), + ); + } + } finally { + setState(() => _isLoading = false); + } + } + + Future _pickImage() async { + try { + final ImagePicker picker = ImagePicker(); + final XFile? image = await picker.pickImage( + source: ImageSource.gallery, + maxWidth: 800, + maxHeight: 800, + imageQuality: 85, + ); + + if (image != null) { + setState(() => _selectedImage = File(image.path)); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to pick image: $e')), + ); + } + } + } + + Future _updateProfile() async { + if (!_formKey.currentState!.validate()) return; + + try { + setState(() => _isLoading = true); + final user = AppPocketBaseService.instance.pb.authStore.record; + + if (user != null) { + final data = { + 'name': _fullNameController.text, + 'region': _selectedRegion, + 'language': _selectedLanguage, + }; + + if (_selectedImage != null) { + final multipartFile = await MultipartFile.fromPath( + 'avatar', + _selectedImage!.path, + filename: 'avatar${DateTime.now().millisecondsSinceEpoch}.jpg', + ); + + await AppPocketBaseService.instance.pb.collection('users').update( + user.id, + body: data, + files: [multipartFile], + ); + } else { + await AppPocketBaseService.instance.pb + .collection('users') + .update(user.id, body: data); + } + + SelectedProfileService.instance.setSelectedProfile( + SelectedProfileService.instance.selectedProfileId, + ); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Profile updated successfully')), + ); + } + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to update profile: $e')), + ); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + Widget _buildTextField({ + required String label, + required IconData icon, + required TextEditingController controller, + String? Function(String?)? validator, + String? tooltip, + bool readOnly = false, + }) { + return Semantics( + textField: true, + label: tooltip ?? label, + child: TextFormField( + controller: controller, + readOnly: readOnly, + style: Theme.of(context).textTheme.bodyLarge, + decoration: InputDecoration( + labelText: label, + prefixIcon: Icon(icon), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline.withOpacity(0.5), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.primary, + width: 2, + ), + ), + filled: true, + fillColor: + Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3), + hoverColor: Theme.of(context).colorScheme.onSurface.withOpacity(0.08), + focusColor: Theme.of(context).colorScheme.onSurface.withOpacity(0.12), + ), + validator: validator, + onFieldSubmitted: (_) => FocusScope.of(context).nextFocus(), + keyboardType: TextInputType.text, + ), + ); + } + + @override + Widget build(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + final horizontalPadding = switch (screenWidth) { + > 1024 => screenWidth * 0.2, + > 600 => 48.0, + _ => 16.0, + }; + + final theme = Theme.of(context); + final isSmallScreen = screenWidth <= 600; + + return Scaffold( + backgroundColor: theme.colorScheme.surface, + appBar: AppBar( + title: const Text('Profile'), + leading: IconButton( + tooltip: 'Back', + icon: const Icon(Icons.arrow_back), + onPressed: () => context.pop(), + ), + actions: [ + TextButton.icon( + onPressed: () => context.push('/settings/security'), + icon: const Icon(Icons.security), + label: const Text('Security'), + ), + ], + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : Focus( + autofocus: true, + child: Shortcuts( + shortcuts: { + LogicalKeySet(LogicalKeyboardKey.tab): + const NextFocusIntent(), + LogicalKeySet( + LogicalKeyboardKey.shift, + LogicalKeyboardKey.tab, + ): const PreviousFocusIntent(), + }, + child: SingleChildScrollView( + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: horizontalPadding, + vertical: isSmallScreen ? 0.0 : 24.0, + ), + child: Form( + key: _formKey, + child: Card( + child: Padding( + padding: EdgeInsets.all(isSmallScreen ? 16.0 : 24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Profile Information', + style: theme.textTheme.titleLarge, + ), + SizedBox(height: isSmallScreen ? 16.0 : 24.0), + Center( + child: Semantics( + button: true, + label: 'Change profile picture', + child: InkWell( + onTap: _pickImage, + borderRadius: BorderRadius.circular(50), + child: Stack( + children: [ + CircleAvatar( + radius: isSmallScreen ? 40 : 60, + backgroundImage: + _selectedImage != null + ? FileImage(_selectedImage!) + : null, + child: _selectedImage == null + ? Icon(Icons.person, + size: isSmallScreen ? 40 : 60) + : null, + ), + Positioned( + bottom: 0, + right: 0, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: theme.colorScheme.primary, + shape: BoxShape.circle, + ), + child: Icon( + Icons.camera_alt, + color: Colors.white, + size: isSmallScreen ? 16 : 20, + ), + ), + ), + ], + ), + ), + ), + ), + SizedBox(height: isSmallScreen ? 16.0 : 24.0), + _buildTextField( + label: 'Email', + icon: Icons.email_outlined, + controller: _emailController, + readOnly: true, + tooltip: 'Your email address', + ), + SizedBox(height: isSmallScreen ? 12.0 : 16.0), + _buildTextField( + label: 'Full Name', + icon: Icons.person_outline, + controller: _fullNameController, + validator: (value) => value?.isEmpty ?? true + ? 'Please enter your name' + : null, + tooltip: 'Enter your full name', + ), + SizedBox(height: isSmallScreen ? 16.0 : 24.0), + LanguageSelector( + initialValue: _selectedLanguage, + onChanged: (value) => + setState(() => _selectedLanguage = value), + ), + SizedBox(height: isSmallScreen ? 16.0 : 24.0), + RegionSelector( + initialValue: _selectedRegion, + onChanged: (value) => + setState(() => _selectedRegion = value), + ), + SizedBox(height: isSmallScreen ? 16.0 : 24.0), + Center( + child: FilledButton( + onPressed: _updateProfile, + child: const Text('Update Profile'), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ), + ); + } + + @override + void dispose() { + _fullNameController.dispose(); + _emailController.dispose(); + super.dispose(); + } +} diff --git a/lib/features/settings/pages/settings/profile_selector.dart b/lib/features/settings/pages/settings/profile_selector.dart new file mode 100644 index 0000000..f7a8cbf --- /dev/null +++ b/lib/features/settings/pages/settings/profile_selector.dart @@ -0,0 +1,223 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:madari_client/features/pocketbase/service/pocketbase.service.dart'; +import 'package:pocketbase/src/dtos/record_model.dart'; +import 'package:pocketbase/src/dtos/result_list.dart'; +import 'package:shimmer/shimmer.dart'; + +import '../../service/selected_profile.dart'; + +class ProfileSelector extends StatefulWidget { + const ProfileSelector({super.key}); + + @override + State createState() => _ProfileSelectorState(); +} + +class _ProfileSelectorState extends State { + final _selectedProfileService = SelectedProfileService.instance; + + late Future> _future; + + late StreamSubscription _listener; + + @override + void initState() { + super.initState(); + + _future = AppPocketBaseService.instance.pb + .collection('account_profile') + .getList(); + + _listener = _selectedProfileService.selectedProfileStream.listen( + (item) { + if (mounted) { + setState(() { + _future = AppPocketBaseService.instance.pb + .collection('account_profile') + .getList(); + }); + } + }, + ); + } + + @override + void dispose() { + super.dispose(); + _listener.cancel(); + } + + Widget _buildShimmerLoading() { + final colorScheme = Theme.of(context).colorScheme; + + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 16), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + mainAxisExtent: 80, + ), + itemCount: 4, + itemBuilder: (context, index) { + return Shimmer.fromColors( + baseColor: colorScheme.surfaceContainerHighest, + highlightColor: colorScheme.surface, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: colorScheme.surfaceContainerHighest, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircleAvatar( + radius: 24, + backgroundColor: colorScheme.surfaceContainerHighest, + ), + const SizedBox(height: 4), + Container( + height: 8, + width: 40, + margin: const EdgeInsets.symmetric(horizontal: 4), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(4), + ), + ), + ], + ), + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return FutureBuilder( + future: _future, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Center( + child: Text('Error loading profiles: ${snapshot.error}'), + ); + } + + if (!snapshot.hasData) { + return _buildShimmerLoading(); + } + + final profiles = snapshot.data!.items; + + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 16), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + mainAxisExtent: 80, + ), + itemCount: profiles.length, + itemBuilder: (context, index) { + final profile = profiles[index]; + + return StreamBuilder( + stream: _selectedProfileService.selectedProfileStream, + builder: (context, snapshot) { + final isSelected = snapshot.data == profile.id; + + return InkWell( + onTap: () async { + final currentSelectedId = + _selectedProfileService.selectedProfileId; + final newSelectedId = currentSelectedId == profile.id + ? profile.id + : profile.id; + await _selectedProfileService + .setSelectedProfile(newSelectedId); + }, + borderRadius: BorderRadius.circular(8), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: isSelected + ? colorScheme.primaryContainer.withOpacity(0.3) + : Colors.transparent, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Stack( + alignment: Alignment.center, + children: [ + if (profile.data['profile_image'] != null && + profile.data['profile_image'] != "") + CircleAvatar( + radius: 24, + backgroundImage: NetworkImage( + AppPocketBaseService.instance.pb.files + .getUrl( + profile, + profile.data['profile_image'], + ) + .toString(), + ), + ) + else + CircleAvatar( + radius: 24, + backgroundColor: isSelected + ? colorScheme.primary + : colorScheme.surfaceVariant, + child: Text( + profile.data['name'][0].toUpperCase(), + style: TextStyle( + color: isSelected + ? colorScheme.onPrimary + : colorScheme.onSurfaceVariant, + fontSize: 18, + fontWeight: isSelected + ? FontWeight.bold + : FontWeight.normal, + ), + ), + ), + ], + ), + const SizedBox(height: 4), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Text( + profile.data['name'], + style: theme.textTheme.bodySmall?.copyWith( + color: isSelected ? colorScheme.primary : null, + fontWeight: isSelected ? FontWeight.bold : null, + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ); + }, + ); + }, + ); + }, + ); + } +} diff --git a/lib/features/settings/pages/settings_page.dart b/lib/features/settings/pages/settings_page.dart new file mode 100644 index 0000000..0145574 --- /dev/null +++ b/lib/features/settings/pages/settings_page.dart @@ -0,0 +1,261 @@ +import 'package:cached_query_flutter/cached_query_flutter.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:madari_client/features/pocketbase/service/pocketbase.service.dart'; +import 'package:madari_client/features/settings/pages/settings/profile_selector.dart'; +import 'package:madari_client/features/settings/service/selected_profile.dart'; + +class SettingsPage extends StatelessWidget { + const SettingsPage({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + final screenWidth = MediaQuery.of(context).size.width; + final isCompact = screenWidth < 600; + + final settingsCategories = [ + _SettingsCategory( + title: 'Account', + items: [ + const _SettingsItem( + title: 'My Account', + icon: Icons.person, + path: '/settings/profile', + description: 'Account settings and preferences', + ), + const _SettingsItem( + title: 'Profiles', + icon: Icons.group, + path: '/settings/subprofiles', + description: 'Manage your profiles', + ), + const _SettingsItem( + title: 'Theme', + icon: Icons.dark_mode, + path: '/settings/appearance', + description: 'Modify application theme', + ), + _SettingsItem( + title: 'Logout', + icon: Icons.logout, + onClick: () { + CachedQuery.instance.deleteCache( + deleteStorage: true, + ); + AppPocketBaseService.instance.pb.authStore.clear(); + SelectedProfileService.instance.setSelectedProfile(null); + context.go("/signin"); + }, + ), + ], + ), + const _SettingsCategory( + title: "Layout", + items: [ + _SettingsItem( + title: "Home Layout", + icon: Icons.layers_outlined, + path: "/layout", + description: "Customize your home page", + ), + ], + ), + const _SettingsCategory( + title: "Connections", + items: [ + _SettingsItem( + title: 'Addons', + icon: Icons.extension_rounded, + path: '/settings/stremio', + description: 'Configure external Addons', + ), + _SettingsItem( + title: 'External Accounts', + icon: Icons.supervisor_account_sharp, + path: '/settings/external-account', + description: 'Configure accounts integration', + ), + ], + ), + const _SettingsCategory( + title: 'Preferences', + items: [ + _SettingsItem( + title: 'Playback', + icon: Icons.play_circle, + path: '/settings/playback', + description: 'Configure playback settings', + ), + ], + ), + const _SettingsCategory( + title: 'System', + items: [ + _SettingsItem( + title: 'Debug', + icon: Icons.bug_report, + path: '/settings/debug', + description: 'Debug options and logs', + ), + _SettingsItem( + title: 'Offline Ratings', + icon: Icons.offline_bolt, + path: '/settings/offline-ratings', + description: 'Configure offline ratings', + ), + ], + ), + ]; + + if (isCompact) { + return Scaffold( + appBar: AppBar( + title: const Text('Settings'), + elevation: 0, + backgroundColor: colorScheme.surface, + ), + body: ListView( + children: [ + const ProfileSelector(), + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: settingsCategories.length, + itemBuilder: (context, categoryIndex) { + final category = settingsCategories[categoryIndex]; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 8), + child: Text( + category.title, + style: theme.textTheme.titleSmall?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ), + ...category.items.map((item) => ListTile( + leading: Icon(item.icon), + title: Text(item.title), + subtitle: item.description != null + ? Text( + item.description!, + style: theme.textTheme.bodySmall, + ) + : null, + trailing: const Icon(Icons.chevron_right), + onTap: () { + if (item.path != null) context.push(item.path!); + if (item.onClick != null) item.onClick!(); + }, + )), + if (categoryIndex < settingsCategories.length - 1) + const Divider(height: 32), + ], + ); + }, + ), + ], + ), + ); + } + + return Scaffold( + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 800), + child: ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), + itemCount: settingsCategories.length, + itemBuilder: (context, categoryIndex) { + final category = settingsCategories[categoryIndex]; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 8, bottom: 8), + child: Text( + category.title, + style: theme.textTheme.titleSmall?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ), + Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: theme.dividerColor, + width: 1, + ), + ), + child: Column( + children: [ + for (int i = 0; i < category.items.length; i++) ...[ + if (i > 0) const Divider(height: 1), + ListTile( + leading: Icon(category.items[i].icon), + title: Text(category.items[i].title), + subtitle: category.items[i].description != null + ? Text( + category.items[i].description!, + style: theme.textTheme.bodySmall, + ) + : null, + trailing: const Icon(Icons.chevron_right), + onTap: () { + final item = category.items[i]; + + if (item.path != null) context.push(item.path!); + if (item.onClick != null) item.onClick!(); + }, + ), + ], + ], + ), + ), + const SizedBox(height: 24), + ], + ); + }, + ), + ), + ), + ); + } +} + +class _SettingsCategory { + final String title; + final List<_SettingsItem> items; + + const _SettingsCategory({ + required this.title, + required this.items, + }); +} + +class _SettingsItem { + final String title; + final IconData icon; + final String? path; + final String? description; + final VoidCallback? onClick; + + const _SettingsItem({ + required this.title, + required this.icon, + this.path, + this.description, + this.onClick, + }); +} diff --git a/lib/features/settings/pages/subprofiles_page.dart b/lib/features/settings/pages/subprofiles_page.dart new file mode 100644 index 0000000..d93d925 --- /dev/null +++ b/lib/features/settings/pages/subprofiles_page.dart @@ -0,0 +1,475 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; +import 'package:pocketbase/pocketbase.dart'; + +import '../../pocketbase/service/pocketbase.service.dart'; +import '../service/account_profile_service.dart'; +import '../service/selected_profile.dart'; +import '../widget/profile_dialog.dart'; + +class SubprofilesPage extends StatefulWidget { + const SubprofilesPage({super.key}); + + @override + State createState() => _SubprofilesPageState(); +} + +class _SubprofilesPageState extends State { + final _logger = Logger('SubprofilesPage'); + final _profileService = AccountProfileService.instance; + final _selectedProfileService = SelectedProfileService.instance; + List _profiles = []; + bool _isLoading = true; + String? _error; + Timer? _retryTimer; + int _retryAttempts = 0; + static const int _maxRetryAttempts = 3; + + late final StreamSubscription _selectedProfileSubscription; + + @override + void initState() { + super.initState(); + _selectedProfileSubscription = _selectedProfileService.selectedProfileStream + .listen((_) => setState(() {})); + _loadProfiles(); + } + + @override + void dispose() { + _selectedProfileSubscription.cancel(); + _retryTimer?.cancel(); + super.dispose(); + } + + Future _loadProfiles({bool isRetry = false}) async { + if (!mounted) return; + + try { + setState(() { + _isLoading = true; + _error = null; + }); + + final profiles = await _profileService.getProfiles(); + + _selectedProfileService.setSelectedProfile( + _selectedProfileService.selectedProfileId, + ); + + if (!mounted) return; + + setState(() { + _profiles = profiles; + _isLoading = false; + _retryAttempts = 0; + }); + } catch (e, stackTrace) { + _logger.severe('Error loading profiles', e, stackTrace); + + if (!mounted) return; + + setState(() { + _isLoading = false; + _error = 'Failed to load profiles: ${e.toString()}'; + }); + + if (isRetry && _retryAttempts < _maxRetryAttempts) { + _retryAttempts++; + _scheduleRetry(); + } + } + } + + void _scheduleRetry() { + _retryTimer?.cancel(); + final backoffDuration = Duration(seconds: pow(2, _retryAttempts).toInt()); + _retryTimer = Timer(backoffDuration, () => _loadProfiles(isRetry: true)); + } + + Future _handleProfileAction(Future Function() action) async { + try { + await action(); + if (!mounted) return; + await _loadProfiles(); + } catch (e, stackTrace) { + _logger.severe('Error performing profile action', e, stackTrace); + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Operation failed: ${e.toString()}'), + action: SnackBarAction( + label: 'Retry', + onPressed: () => _handleProfileAction(action), + ), + ), + ); + } + } + + Future _handleProfileSelection(RecordModel profile) async { + try { + final currentSelectedId = _selectedProfileService.selectedProfileId; + final newSelectedId = + currentSelectedId == profile.id ? profile.id : profile.id; + await _selectedProfileService.setSelectedProfile(newSelectedId); + + if (!mounted) return; + } catch (e, stackTrace) { + _logger.severe('Error selecting profile', e, stackTrace); + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to select profile: ${e.toString()}'), + ), + ); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Scaffold( + backgroundColor: colorScheme.surface, + appBar: AppBar( + title: Text('Profiles', style: theme.textTheme.headlineMedium), + actions: [ + IconButton( + icon: const Icon(Icons.add), + tooltip: 'Add new profile', + onPressed: () => _showProfileDialog(context), + focusNode: FocusNode(skipTraversal: false), + ), + ], + ), + body: FocusTraversalGroup( + child: _buildBody(), + ), + ); + } + + Widget _buildBody() { + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (_error != null) { + return ErrorRetryWidget( + error: _error!, + onRetry: () => _loadProfiles(isRetry: true), + ); + } + + if (_profiles.isEmpty) { + return Center( + child: Text( + 'No profiles found. Create one to get started.', + style: Theme.of(context).textTheme.bodyLarge, + ), + ); + } + + return CustomScrollView( + slivers: [ + SliverPadding( + padding: const EdgeInsets.all(16), + sliver: SliverGrid( + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 300, + mainAxisSpacing: 16, + crossAxisSpacing: 16, + childAspectRatio: 0.8, + ), + delegate: SliverChildBuilderDelegate( + (context, index) => _buildProfileCard(_profiles[index]), + childCount: _profiles.length, + ), + ), + ), + ], + ); + } + + Widget _buildProfileCard(RecordModel profile) { + return ProfileCard( + profile: profile, + selectedProfileId: _selectedProfileService.selectedProfileId, + onTap: () => _handleProfileSelection(profile), + onEdit: () => _showProfileDialog(context, profile: profile), + onDelete: () => _showDeleteDialog(profile), + ); + } + + Future _showProfileDialog(BuildContext context, + {RecordModel? profile}) async { + final result = await showDialog( + context: context, + builder: (context) => ProfileDialog(profile: profile), + ); + + if (result == true) { + _loadProfiles(); + } + } + + Future _showDeleteDialog(RecordModel profile) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete Profile'), + content: Text( + 'Are you sure you want to delete ${profile.getStringValue('name')}?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Delete'), + ), + ], + ), + ); + + if (confirmed == true) { + await _handleProfileAction( + () => _profileService.deleteProfile(profile.id)); + } + } +} + +class ErrorRetryWidget extends StatelessWidget { + final String error; + final VoidCallback onRetry; + + const ErrorRetryWidget({ + super.key, + required this.error, + required this.onRetry, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + error, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).colorScheme.error, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + ), + ], + ), + ), + ); + } +} + +class ProfileCard extends StatelessWidget { + final RecordModel profile; + final String? selectedProfileId; + final double size; + final VoidCallback? onTap; + final VoidCallback? onEdit; + final VoidCallback? onDelete; + + const ProfileCard({ + super.key, + required this.profile, + this.selectedProfileId, + this.size = 150, + this.onTap, + this.onEdit, + this.onDelete, + }); + + bool get isSelected => profile.id == selectedProfileId; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return FocusTraversalGroup( + child: SizedBox( + width: size, + height: size * 1.2, + child: Material( + elevation: isSelected ? 8 : 2, + shadowColor: colorScheme.shadow.withAlpha(77), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide( + color: isSelected ? colorScheme.primary : Colors.transparent, + width: 2, + ), + ), + color: + isSelected ? colorScheme.primaryContainer : colorScheme.surface, + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + const SizedBox(height: 16), + _buildProfileImage(colorScheme), + const SizedBox(height: 12), + _buildNameText(theme), + const Spacer(), + _buildActionButtons(context, colorScheme), + ], + ), + ), + ), + ), + ), + ); + } + + Widget _buildActionButtons(BuildContext context, ColorScheme colorScheme) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (onEdit != null) + IconButton( + icon: const Icon(Icons.edit_outlined, size: 20), + onPressed: onEdit, + visualDensity: VisualDensity.compact, + style: IconButton.styleFrom( + backgroundColor: colorScheme.surface.withAlpha(230), + foregroundColor: colorScheme.onSurface, + padding: const EdgeInsets.all(8), + minimumSize: const Size(36, 36), + ), + ), + if (onDelete != null) const SizedBox(width: 8), + if (onDelete != null) + IconButton( + icon: const Icon(Icons.delete_outlined, size: 20), + onPressed: onDelete, + visualDensity: VisualDensity.compact, + style: IconButton.styleFrom( + backgroundColor: colorScheme.surface.withAlpha(230), + foregroundColor: colorScheme.error, + padding: const EdgeInsets.all(8), + minimumSize: const Size(36, 36), + ), + ), + ], + ); + } + + Widget _buildProfileImage(ColorScheme colorScheme) { + final imageSize = size * 0.5; + + return SizedBox( + width: imageSize, + height: imageSize, + child: Stack( + clipBehavior: Clip.none, + children: [ + Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: isSelected + ? colorScheme.primary + : colorScheme.outline.withAlpha(51), + width: 2, + ), + image: _getProfileImage(), + ), + child: _buildPlaceholderIcon(colorScheme), + ), + if (isSelected) + Positioned( + right: -4, + bottom: -4, + child: _buildSelectedIndicator(colorScheme), + ), + ], + ), + ); + } + + DecorationImage? _getProfileImage() { + final imageUrl = profile.getStringValue('profile_image'); + if (imageUrl.isEmpty) return null; + + return DecorationImage( + image: NetworkImage( + AppPocketBaseService.instance.pb.files + .getUrl(profile, imageUrl) + .toString(), + ), + fit: BoxFit.cover, + ); + } + + Widget? _buildPlaceholderIcon(ColorScheme colorScheme) { + if (profile.getStringValue('profile_image').isNotEmpty) return null; + + return Center( + child: Icon( + Icons.person, + size: size * 0.25, + color: isSelected ? colorScheme.primary : colorScheme.outline, + ), + ); + } + + Widget _buildSelectedIndicator(ColorScheme colorScheme) { + return Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: colorScheme.primary, + shape: BoxShape.circle, + border: Border.all( + color: colorScheme.surface, + width: 2, + ), + ), + child: Icon( + Icons.check, + size: 16, + color: colorScheme.onPrimary, + ), + ); + } + + Widget _buildNameText(ThemeData theme) { + return Text( + profile.getStringValue('name'), + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: isSelected + ? theme.colorScheme.primary + : theme.colorScheme.onSurface, + ), + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + maxLines: 2, + ); + } +} diff --git a/lib/features/settings/screen/account_screen.dart b/lib/features/settings/screen/account_screen.dart deleted file mode 100644 index 8ca1c0f..0000000 --- a/lib/features/settings/screen/account_screen.dart +++ /dev/null @@ -1,89 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:madari_client/features/settings/screen/profile_button.dart'; - -import '../../../engine/engine.dart'; -import '../navigation/account_navigation.dart'; - -class AccountScreen extends StatelessWidget { - const AccountScreen({super.key}); - - Widget _buildDivider() { - return const Divider(height: 1, thickness: 1); - } - - Widget _buildSectionHeader(String title) { - return Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text( - title, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: Colors.grey, - ), - ), - ); - } - - AppEngine get engine => AppEngine.engine; - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.black, - appBar: AppBar( - title: const Text('My Account'), - elevation: 0, - ), - body: Center( - child: Container( - constraints: const BoxConstraints( - maxWidth: 600, - ), - child: ListView( - children: [ - ProfileButton(), - _buildDivider(), - _buildSectionHeader('ACCOUNT SETTINGS'), - ListTile( - leading: const Icon(Icons.email), - title: const Text('Email Settings'), - subtitle: const Text('Manage your email preferences'), - onTap: () => AccountNavigation.navigateToEmailSettings(context), - ), - _buildDivider(), - ListTile( - leading: const Icon(Icons.security), - title: const Text('Security'), - subtitle: const Text('Password and security settings'), - onTap: () => AccountNavigation.navigateToSecurity(context), - ), - _buildDivider(), - ListTile( - leading: const Icon(Icons.notifications), - title: const Text('Notifications'), - subtitle: const Text('Manage notification preferences'), - onTap: () => AccountNavigation.navigateToNotifications(context), - ), - // _buildSectionHeader('PAYMENT'), - // ListTile( - // leading: const Icon(Icons.payment), - // title: const Text('Payment Methods'), - // subtitle: const Text('Manage your payment options'), - // onTap: () => AccountNavigation.navigateToPayments(context), - // ), - // _buildSectionHeader('SUPPORT'), - // ListTile( - // leading: const Icon(Icons.help), - // title: const Text('Help Center'), - // subtitle: const Text('Get help and contact support'), - // onTap: () => AccountNavigation.navigateToHelp(context), - // ), - const SizedBox(height: 32), - ], - ), - ), - ), - ); - } -} diff --git a/lib/features/settings/screen/connection_screen.dart b/lib/features/settings/screen/connection_screen.dart deleted file mode 100644 index 85708d4..0000000 --- a/lib/features/settings/screen/connection_screen.dart +++ /dev/null @@ -1,78 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:madari_client/features/connection/containers/connection_manager.dart'; -import 'package:madari_client/features/getting_started/container/getting_started.dart'; -import 'package:madari_client/features/library/containers/connection_list.dart'; - -import '../../../engine/connection.dart'; - -class ConnectionsScreen extends StatefulWidget { - const ConnectionsScreen({super.key}); - - @override - State createState() => _ConnectionsScreenState(); -} - -class _ConnectionsScreenState extends State { - final scaffoldState = GlobalKey(); - - @override - Widget build(BuildContext context) { - return Consumer( - builder: (BuildContext context, WidgetRef ref, Widget? child) { - return Scaffold( - key: scaffoldState, - appBar: AppBar( - title: const Text("My Connections"), - ), - floatingActionButton: FloatingActionButton.extended( - onPressed: () { - showModalBottomSheet( - context: context, - isScrollControlled: true, - isDismissible: true, - builder: (context) { - return Padding( - padding: const EdgeInsets.only( - top: 18.0, - ), - child: GettingStartedScreen( - onCallback: () { - ref.refresh(getConnectionsProvider); - }, - hasBackground: false, - ), - ); - }, - ); - }, - icon: const Icon(Icons.add), - label: const Text( - "New connection", - ), - ), - body: Center( - child: Container( - constraints: const BoxConstraints( - maxWidth: 600, - ), - child: ConnectionList( - shrinkWrap: false, - canDisconnect: true, - onTap: (item) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (ctx) => ConnectionManager( - item: item, - ), - ), - ); - }, - ), - ), - ), - ); - }, - ); - } -} diff --git a/lib/features/settings/screen/email_settings_screen.dart b/lib/features/settings/screen/email_settings_screen.dart deleted file mode 100644 index 40c7198..0000000 --- a/lib/features/settings/screen/email_settings_screen.dart +++ /dev/null @@ -1,57 +0,0 @@ -import 'package:flutter/material.dart'; - -class EmailSettingsScreen extends StatefulWidget { - const EmailSettingsScreen({super.key}); - - @override - State createState() => _EmailSettingsScreenState(); -} - -class _EmailSettingsScreenState extends State { - bool marketingEmails = true; - bool newsLetters = true; - bool accountAlerts = true; - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Email Settings'), - ), - body: ListView( - children: [ - SwitchListTile( - title: const Text('Marketing Emails'), - subtitle: const Text('Receive promotional offers and updates'), - value: marketingEmails, - onChanged: (bool value) { - setState(() { - marketingEmails = value; - }); - }, - ), - SwitchListTile( - title: const Text('Newsletters'), - subtitle: const Text('Receive weekly newsletters'), - value: newsLetters, - onChanged: (bool value) { - setState(() { - newsLetters = value; - }); - }, - ), - SwitchListTile( - title: const Text('Account Alerts'), - subtitle: const Text('Receive important account notifications'), - value: accountAlerts, - onChanged: (bool value) { - setState(() { - accountAlerts = value; - }); - }, - ), - ], - ), - ); - } -} diff --git a/lib/features/settings/screen/help_screen.dart b/lib/features/settings/screen/help_screen.dart deleted file mode 100644 index c619331..0000000 --- a/lib/features/settings/screen/help_screen.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'package:flutter/material.dart'; - -class HelpScreen extends StatelessWidget { - const HelpScreen({super.key}); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Help Center'), - ), - body: ListView( - children: [ - ListTile( - leading: const Icon(Icons.article), - title: const Text('FAQs'), - onTap: () { - // Navigate to FAQs - }, - ), - ListTile( - leading: const Icon(Icons.chat), - title: const Text('Contact Support'), - onTap: () { - // Open chat support - }, - ), - ListTile( - leading: const Icon(Icons.email), - title: const Text('Email Support'), - onTap: () { - // Open email support - }, - ), - ListTile( - leading: const Icon(Icons.phone), - title: const Text('Call Support'), - onTap: () { - // Make support call - }, - ), - const Padding( - padding: EdgeInsets.all(16), - child: Text( - 'Support Hours:\nMonday - Friday: 9:00 AM - 5:00 PM\nWeekends: 10:00 AM - 3:00 PM', - style: TextStyle(color: Colors.grey), - ), - ), - ], - ), - ); - } -} diff --git a/lib/features/settings/screen/logs_screen.dart b/lib/features/settings/screen/logs_screen.dart deleted file mode 100644 index 2bf63e8..0000000 --- a/lib/features/settings/screen/logs_screen.dart +++ /dev/null @@ -1,194 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -import '../../../data/global_logs.dart'; - -class LogsPage extends StatefulWidget { - const LogsPage({super.key}); - - @override - State createState() => _LogsPageState(); -} - -class _LogsPageState extends State { - List parsedLogs = []; - - @override - void initState() { - super.initState(); - _parseLogs(); - } - - void _parseLogs() { - parsedLogs = globalLogs.reversed.map((log) => LogEntry.parse(log)).toList(); - } - - void _copyToClipboard() { - Clipboard.setData(ClipboardData(text: globalLogs.join('\n'))); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Logs copied to clipboard'), - behavior: SnackBarBehavior.floating, - duration: Duration(seconds: 2), - ), - ); - } - - Color _getLevelColor(String level) { - switch (level.toUpperCase()) { - case 'ERROR': - return Colors.red; - case 'WARN': - case 'WARNING': - return Colors.orange; - case 'INFO': - return Colors.blue; - case 'DEBUG': - return Colors.grey; - default: - return Colors.grey; - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text( - "Application Logs", - style: TextStyle(fontWeight: FontWeight.bold), - ), - actions: [ - IconButton( - onPressed: _copyToClipboard, - icon: const Icon(Icons.copy), - tooltip: 'Copy all logs', - ), - IconButton( - onPressed: () { - setState(() { - _parseLogs(); - }); - }, - icon: const Icon(Icons.refresh), - tooltip: 'Refresh logs', - ), - ], - ), - body: parsedLogs.isEmpty - ? const Center( - child: Text( - 'No logs available', - style: TextStyle(fontSize: 16, color: Colors.grey), - ), - ) - : ListView.builder( - itemCount: parsedLogs.length, - itemBuilder: (context, index) { - final log = parsedLogs[index]; - return Container( - padding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: Colors.grey.withOpacity(0.2), - width: 1, - ), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: _getLevelColor(log.level).withOpacity(0.1), - borderRadius: BorderRadius.circular(3), - border: Border.all( - color: - _getLevelColor(log.level).withOpacity(0.3), - ), - ), - child: Text( - log.level, - style: TextStyle( - fontSize: 11, - color: _getLevelColor(log.level), - fontWeight: FontWeight.bold, - ), - ), - ), - const SizedBox(width: 8), - Text( - '${log.service} • ', - style: TextStyle( - fontSize: 12, - color: Colors.grey[700], - ), - ), - Text( - log.timestamp, - style: TextStyle( - fontSize: 12, - color: Colors.grey[600], - fontFamily: 'monospace', - ), - ), - ], - ), - const SizedBox(height: 4), - SelectableText( - log.message, - style: const TextStyle( - fontSize: 13, - fontFamily: 'monospace', - ), - ), - ], - ), - ); - }, - ), - ); - } -} - -class LogEntry { - final String level; - final String service; - final String timestamp; - final String message; - - LogEntry({ - required this.level, - required this.service, - required this.timestamp, - required this.message, - }); - - factory LogEntry.parse(String logLine) { - final parts = logLine.split(RegExp(r'\s+')); - if (parts.length >= 3) { - final level = parts[0]; - final service = parts[1]; - final timestamp = parts[2]; - final message = parts.skip(3).join(' '); - return LogEntry( - level: level, - service: service, - timestamp: timestamp, - message: message, - ); - } - return LogEntry( - level: 'UNKNOWN', - service: 'Unknown', - timestamp: '', - message: logLine, - ); - } -} diff --git a/lib/features/settings/screen/notification_screen.dart b/lib/features/settings/screen/notification_screen.dart deleted file mode 100644 index 08ba9ce..0000000 --- a/lib/features/settings/screen/notification_screen.dart +++ /dev/null @@ -1,57 +0,0 @@ -import 'package:flutter/material.dart'; - -class NotificationsScreen extends StatefulWidget { - const NotificationsScreen({super.key}); - - @override - State createState() => _NotificationsScreenState(); -} - -class _NotificationsScreenState extends State { - bool pushNotifications = true; - bool soundEnabled = true; - bool vibrationEnabled = true; - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Notifications'), - ), - body: ListView( - children: [ - SwitchListTile( - title: const Text('Push Notifications'), - subtitle: const Text('Enable push notifications'), - value: pushNotifications, - onChanged: (bool value) { - setState(() { - pushNotifications = value; - }); - }, - ), - SwitchListTile( - title: const Text('Sound'), - subtitle: const Text('Play sound for notifications'), - value: soundEnabled, - onChanged: (bool value) { - setState(() { - soundEnabled = value; - }); - }, - ), - SwitchListTile( - title: const Text('Vibration'), - subtitle: const Text('Vibrate for notifications'), - value: vibrationEnabled, - onChanged: (bool value) { - setState(() { - vibrationEnabled = value; - }); - }, - ), - ], - ), - ); - } -} diff --git a/lib/features/settings/screen/payment_screen.dart b/lib/features/settings/screen/payment_screen.dart deleted file mode 100644 index d33f197..0000000 --- a/lib/features/settings/screen/payment_screen.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'package:flutter/material.dart'; - -class PaymentScreen extends StatelessWidget { - const PaymentScreen({super.key}); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Payment Methods'), - ), - body: ListView( - children: [ - ListTile( - leading: const Icon(Icons.add_circle_outline), - title: const Text('Add Payment Method'), - onTap: () { - // Implement add payment method logic - }, - ), - const Divider(), - ListTile( - leading: const Icon(Icons.credit_card), - title: const Text('•••• •••• •••• 1234'), - subtitle: const Text('Expires 12/24'), - trailing: IconButton( - icon: const Icon(Icons.more_vert), - onPressed: () { - // Show card options - }, - ), - ), - ListTile( - leading: const Icon(Icons.payment), - title: const Text('PayPal'), - subtitle: const Text('john.doe@example.com'), - trailing: IconButton( - icon: const Icon(Icons.more_vert), - onPressed: () { - // Show PayPal options - }, - ), - ), - ], - ), - ); - } -} diff --git a/lib/features/settings/screen/playback_settings_screen.dart b/lib/features/settings/screen/playback_settings_screen.dart deleted file mode 100644 index b98b4eb..0000000 --- a/lib/features/settings/screen/playback_settings_screen.dart +++ /dev/null @@ -1,436 +0,0 @@ -import 'dart:async'; - -import 'package:flex_color_picker/flex_color_picker.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:madari_client/utils/external_player.dart'; -import 'package:pocketbase/pocketbase.dart'; - -import '../../../engine/engine.dart'; -import '../../../utils/load_language.dart'; - -class PlaybackSettingsScreen extends StatefulWidget { - const PlaybackSettingsScreen({super.key}); - - @override - State createState() => _PlaybackSettingsScreenState(); -} - -class _PlaybackSettingsScreenState extends State { - String? _error; - Timer? _saveDebouncer; - - bool _autoPlay = true; - double _playbackSpeed = 1.0; - double _subtitleSize = 10.0; - String _defaultAudioTrack = 'eng'; - String _defaultSubtitleTrack = 'eng'; - bool _enableExternalPlayer = true; - String? _defaultPlayerId; - bool _disabledSubtitle = false; - Map _availableLanguages = {}; - final List _subtitleStyle = [ - 'normal', - 'italic', - ]; - bool _softwareAcceleration = false; - String? _selectedSubtitleStyle; - String colorToHex(Color color) { - return '#${color.value.toRadixString(16).padLeft(8, '0').toUpperCase()}'; - } - - Color hexToColor(String hexColor) { - final hexCode = hexColor.replaceAll('#', ''); - return Color(int.parse('0x$hexCode')); - } - - Color _selectedSubtitleColor = Colors.white; - - _showColorPickerDialog(BuildContext context) async { - Color? color = await showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - title: const Text('Pick a Subtitle Color'), - content: SingleChildScrollView( - child: ColorPicker( - padding: const EdgeInsets.all(0), - color: _selectedSubtitleColor, - onColorChanged: (Color color) { - _selectedSubtitleColor = color; - }, - // Remove pickerType - enableShadesSelection: true, - ), - ), - actions: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: const Text('Cancel'), - ), - TextButton( - onPressed: () { - Navigator.of(context).pop(_selectedSubtitleColor); - }, - child: const Text('OK'), - ), - ], - ); - }, - ); - if (color != null) { - setState(() { - _selectedSubtitleColor = color; - }); - _debouncedSave(); // Debounced save after color change - } - } - - List> get dropdown => - _availableLanguages.entries.map((item) { - return DropdownMenuItem( - value: item.key, - child: Text(item.value), - ); - }).toList(); - - final PocketBase _engine = AppEngine.engine.pb; - - @override - void initState() { - super.initState(); - - loadLanguages(context).then((data) { - setState(() { - _availableLanguages = data; - }); - }); - _loadPlaybackSettings(); - } - - void _loadPlaybackSettings() { - PlaybackConfig playbackConfig; - try { - playbackConfig = getPlaybackConfig(); - } catch (e) { - playbackConfig = PlaybackConfig.fromJson({}); - } - - _autoPlay = playbackConfig.autoPlay ?? true; - _playbackSpeed = playbackConfig.playbackSpeed.toDouble(); - _defaultAudioTrack = playbackConfig.defaultAudioTrack; - _defaultSubtitleTrack = playbackConfig.defaultSubtitleTrack; - _enableExternalPlayer = playbackConfig.externalPlayer; - _softwareAcceleration = playbackConfig.softwareAcceleration; - _defaultPlayerId = - playbackConfig.externalPlayerId?.containsKey(currentPlatform) == true - ? playbackConfig.externalPlayerId![currentPlatform] - : null; - _disabledSubtitle = playbackConfig.disableSubtitle; - _selectedSubtitleStyle = - (playbackConfig.subtitleStyle ?? "normal").toLowerCase(); - _selectedSubtitleColor = playbackConfig.subtitleColor != null - ? hexToColor(playbackConfig.subtitleColor!) - : Colors.white; - _subtitleSize = playbackConfig.subtitleSize.toDouble(); - } - - @override - void dispose() { - _saveDebouncer?.cancel(); - super.dispose(); - } - - void _debouncedSave() { - _saveDebouncer?.cancel(); - _saveDebouncer = Timer( - const Duration(milliseconds: 500), - _savePlaybackSettings, - ); - } - - Future _savePlaybackSettings() async { - try { - final user = _engine.authStore.record; - if (user == null) { - throw Exception('User not authenticated'); - } - - final currentConfig = user.data['config'] as Map? ?? {}; - - final extranalId = currentConfig['externalPlayerId'] ?? {}; - - extranalId[currentPlatform] = _defaultPlayerId; - - final updatedConfig = { - ...currentConfig, - 'playback': { - 'autoPlay': _autoPlay, - 'playbackSpeed': _playbackSpeed, - 'defaultAudioTrack': _defaultAudioTrack, - 'defaultSubtitleTrack': _defaultSubtitleTrack, - 'externalPlayer': _enableExternalPlayer, - 'externalPlayerId': extranalId, - 'disableSubtitle': _disabledSubtitle, - 'subtitleStyle': _selectedSubtitleStyle, - 'subtitleColor': colorToHex(_selectedSubtitleColor), - 'subtitleSize': _subtitleSize, - 'softwareAcceleration': _softwareAcceleration, - }, - }; - - await _engine.collection('users').update( - user.id, - body: { - 'config': updatedConfig, - }, - ); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Settings saved'), - duration: Duration(seconds: 1), - ), - ); - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Failed to save settings: ${e.toString()}')), - ); - } - } - } - - final currentPlatform = getPlatformInString(); - - @override - Widget build(BuildContext context) { - final dropdownstyle = _subtitleStyle.map((String value) { - return DropdownMenuItem( - value: value, - child: Text(value.capitalize), - ); - }).toList(); - if (_error != null) { - return Scaffold( - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text(_error!, style: const TextStyle(color: Colors.red)), - ElevatedButton( - onPressed: _loadPlaybackSettings, - child: const Text('Retry'), - ), - ], - ), - ), - ); - } - - return Scaffold( - appBar: AppBar( - title: const Text('Playback Settings'), - ), - body: Center( - child: Container( - constraints: const BoxConstraints( - maxWidth: 600, - ), - child: ListView( - children: [ - SwitchListTile( - title: const Text('Auto-play'), - subtitle: const Text('Automatically play next content'), - value: _autoPlay, - onChanged: (value) { - setState(() => _autoPlay = value); - _debouncedSave(); - }, - ), - ListTile( - title: const Text('Playback Speed'), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Slider( - value: _playbackSpeed, - min: 0.5, - max: 5.0, - divisions: 18, - label: '${_playbackSpeed.toStringAsFixed(2)}x', - onChanged: (value) { - HapticFeedback.mediumImpact(); - setState(() => _playbackSpeed = - double.parse(value.toStringAsFixed(2))); - _debouncedSave(); - }, - ), - Text('Current: ${_playbackSpeed.toStringAsFixed(2)}x'), - ], - ), - ), - const Divider(), - ListTile( - title: const Text('Default Audio Track'), - trailing: DropdownButton( - value: _defaultAudioTrack, - items: dropdown, - onChanged: (value) { - if (value != null) { - setState(() => _defaultAudioTrack = value); - _debouncedSave(); - } - }, - ), - ), - SwitchListTile( - title: const Text('Software Acceleration'), - value: _softwareAcceleration, - onChanged: (value) { - setState(() => _softwareAcceleration = value); - _debouncedSave(); - }, - ), - SwitchListTile( - title: const Text('Disable Subtitle'), - value: _disabledSubtitle, - onChanged: (value) { - setState(() => _disabledSubtitle = value); - _debouncedSave(); - }, - ), - if (!_disabledSubtitle) ...[ - ListTile( - title: const Text('Default Subtitle Track'), - trailing: DropdownButton( - value: _defaultSubtitleTrack, - items: dropdown, - onChanged: (value) { - if (value != null) { - setState(() => _defaultSubtitleTrack = value); - _debouncedSave(); - } - }, - ), - ), - Padding( - padding: const EdgeInsets.all(12.0), - child: Material( - child: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.8, - ), - child: Text( - 'Sample Text', - textAlign: - TextAlign.center, // Center text within its box - style: TextStyle( - fontSize: _subtitleSize / 2, - color: _selectedSubtitleColor, - fontStyle: _subtitleStyle[0].toLowerCase() == 'italic' - ? FontStyle.italic - : FontStyle.normal, - ), - ), - ), - ), - ), - ListTile( - title: const Text('Subtitle Style'), - trailing: DropdownButton( - value: _selectedSubtitleStyle, - items: dropdownstyle, - onChanged: (value) { - HapticFeedback.mediumImpact(); - if (value != null) { - setState(() { - _selectedSubtitleStyle = value; - }); - _debouncedSave(); - } - }, - ), - ), - ListTile( - title: const Text('Subtitle Color'), - trailing: GestureDetector( - // Use GestureDetector to make the color display tappable - onTap: () => _showColorPickerDialog(context), - child: Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: _selectedSubtitleColor, - borderRadius: BorderRadius.circular(5), - border: Border.all(color: Colors.grey), - ), - ), - ), - ), - ListTile( - title: const Text('Font Size'), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Slider( - value: _subtitleSize, - min: 10.0, - max: 60.0, - divisions: 18, - label: '${_subtitleSize.toStringAsFixed(2)}x', - onChanged: (value) { - HapticFeedback.lightImpact(); - setState( - () => _subtitleSize = - double.parse(value.toStringAsFixed(2)), - ); - _debouncedSave(); - }, - ), - Text('Current: ${_subtitleSize.toStringAsFixed(2)}x'), - ], - ), - ), - ], - const Divider(), - if (!isWeb) - SwitchListTile( - title: const Text('External Player'), - subtitle: const Text('Always open video in external player?'), - value: _enableExternalPlayer, - onChanged: (value) { - setState(() => _enableExternalPlayer = value); - _debouncedSave(); - }, - ), - if (_enableExternalPlayer && - externalPlayers[currentPlatform]?.isNotEmpty == true) - ListTile( - title: const Text('Default Player'), - trailing: DropdownButton( - value: _defaultPlayerId == "" ? null : _defaultPlayerId, - items: externalPlayers[currentPlatform]! - .map( - (item) => item.toDropdownMenuItem(), - ) - .toList(), - onChanged: (value) { - if (value != null) { - setState(() => _defaultPlayerId = value); - _debouncedSave(); - } - }, - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/features/settings/screen/profile_button.dart b/lib/features/settings/screen/profile_button.dart deleted file mode 100644 index 6c8585f..0000000 --- a/lib/features/settings/screen/profile_button.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:madari_client/engine/engine.dart'; - -import '../navigation/account_navigation.dart'; - -class ProfileButton extends StatelessWidget { - final engine = AppEngine.engine; - - ProfileButton({super.key}); - - @override - Widget build(BuildContext context) { - final record = engine.pb.authStore.record; - - final name = record?.getStringValue("name") ?? ""; - final email = record?.getStringValue("email") ?? ""; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ListTile( - leading: const CircleAvatar( - child: Icon(Icons.person), - ), - title: Text("$name ($email)"), - subtitle: const Text('View and edit profile'), - onTap: () => AccountNavigation.navigateToProfile(context), - ) - ], - ); - } -} diff --git a/lib/features/settings/screen/profile_setting.dart b/lib/features/settings/screen/profile_setting.dart deleted file mode 100644 index 97d5803..0000000 --- a/lib/features/settings/screen/profile_setting.dart +++ /dev/null @@ -1,223 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:http/http.dart'; -import 'package:image_picker/image_picker.dart'; -import 'package:madari_client/engine/engine.dart'; -import 'package:pocketbase/pocketbase.dart'; - -import '../types/user_profile.dart'; - -class ProfileScreen extends StatefulWidget { - const ProfileScreen({super.key}); - - @override - State createState() => _ProfileScreenState(); -} - -class _ProfileScreenState extends State { - PocketBase get pocketBase => AppEngine.engine.pb; - late UserProfile _userProfile; - bool _isLoading = true; - bool _isEditing = false; - - final _formKey = GlobalKey(); - late TextEditingController _nameController; - late TextEditingController _emailController; - - @override - void initState() { - super.initState(); - _loadUserProfile(); - _initializeControllers(); - } - - void _initializeControllers() { - _nameController = TextEditingController(); - _emailController = TextEditingController(); - } - - Future _loadUserProfile() async { - try { - final userId = pocketBase.authStore.record!.id; - final record = await pocketBase.collection('users').getOne(userId); - setState(() { - _userProfile = UserProfile.fromJson(record.toJson()); - _isLoading = false; - _updateControllers(); - }); - } catch (e) { - if (context.mounted && mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Error loading profile: $e')), - ); - } - } - } - - void _updateControllers() { - _nameController.text = _userProfile.fullName; - _emailController.text = _userProfile.email; - } - - Future _updateProfile() async { - if (!_formKey.currentState!.validate()) return; - - try { - final userId = pocketBase.authStore.record!.id; - final body = { - 'name': _nameController.text, - 'email': _emailController.text, - }; - - await pocketBase.collection('users').update(userId, body: body); - - setState(() { - _userProfile = UserProfile( - id: userId, - fullName: _nameController.text, - email: _emailController.text, - avatar: _userProfile.avatar, - ); - _isEditing = false; - }); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Profile updated successfully')), - ); - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Error updating profile: $e')), - ); - } - } - } - - Future _uploadAvatar() async { - final ImagePicker picker = ImagePicker(); - final XFile? image = await picker.pickImage(source: ImageSource.gallery); - - if (image == null) return; - - try { - final userId = pocketBase.authStore.record!.id; - final file = File(image.path); - - await pocketBase.collection('users').update( - userId, - files: [await MultipartFile.fromPath('avatar', file.path)], - ); - - await _loadUserProfile(); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Avatar updated successfully')), - ); - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Error uploading avatar: $e')), - ); - } - } - } - - @override - Widget build(BuildContext context) { - if (_isLoading) { - return const Scaffold( - body: Center(child: CircularProgressIndicator()), - ); - } - - return Scaffold( - appBar: AppBar( - title: const Text('Profile Information'), - actions: [ - IconButton( - icon: Icon(_isEditing ? Icons.save : Icons.edit), - onPressed: () { - if (_isEditing) { - _updateProfile(); - } else { - setState(() => _isEditing = true); - } - }, - ), - ], - ), - body: Form( - key: _formKey, - child: ListView( - padding: const EdgeInsets.all(16), - children: [ - GestureDetector( - onTap: _uploadAvatar, - child: CircleAvatar( - radius: 50, - backgroundImage: _userProfile.avatar != null - ? NetworkImage( - '${pocketBase.baseURL}/api/files/users/${_userProfile.id}/${_userProfile.avatar}', - ) - : null, - child: _userProfile.avatar == null - ? const Icon(Icons.person, size: 50) - : null, - ), - ), - const SizedBox(height: 24), - _buildProfileField( - 'Full Name', - _nameController, - Icons.person, - _isEditing, - ), - _buildProfileField( - 'Email', - _emailController, - Icons.email, - _isEditing, - ), - ], - ), - ), - ); - } - - Widget _buildProfileField( - String label, - TextEditingController controller, - IconData icon, - bool isEditing, - ) { - return ListTile( - leading: Icon(icon), - title: Text(label), - subtitle: isEditing - ? TextFormField( - controller: controller, - decoration: const InputDecoration( - border: OutlineInputBorder(), - ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter $label'; - } - return null; - }, - ) - : Text(controller.text), - ); - } - - @override - void dispose() { - _nameController.dispose(); - _emailController.dispose(); - super.dispose(); - } -} diff --git a/lib/features/settings/screen/screen_proxy_setting.dart b/lib/features/settings/screen/screen_proxy_setting.dart deleted file mode 100644 index feb2048..0000000 --- a/lib/features/settings/screen/screen_proxy_setting.dart +++ /dev/null @@ -1,179 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:http/http.dart' as http; -import 'package:madari_client/engine/engine.dart'; -import 'package:pocketbase/pocketbase.dart'; - -class ScreenProxySetting extends StatefulWidget { - const ScreenProxySetting({super.key}); - - @override - State createState() => _ScreenProxySettingState(); -} - -class _ScreenProxySettingState extends State { - final PocketBase pb = AppEngine.engine.pb; - - late Future> _collectionItems; - - RecordService get collection => pb.collection("proxy_setting"); - - @override - void initState() { - super.initState(); - _collectionItems = collection.getList(); - } - - void _showAddProxySheet(BuildContext context) { - final nameController = TextEditingController(); - final urlController = TextEditingController(); - final passwordController = TextEditingController(); - - showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (ctx) => DraggableScrollableSheet( - expand: false, - builder: (BuildContext context, ScrollController scrollController) { - return Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, - left: 16, - right: 16, - top: 16, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: nameController, - decoration: const InputDecoration(labelText: 'Name'), - ), - const SizedBox(height: 8), - TextField( - controller: urlController, - decoration: const InputDecoration(labelText: 'URL'), - ), - const SizedBox(height: 8), - TextField( - controller: passwordController, - decoration: const InputDecoration(labelText: 'Password'), - obscureText: true, - ), - const SizedBox(height: 16), - ElevatedButton( - onPressed: () async { - try { - final url = urlController.text.endsWith("/") - ? urlController.text.substring(0, -1) - : urlController.text; - - final result = await http.get( - Uri.parse( - "$url/proxy/ip?api_password=${Uri.encodeQueryComponent(passwordController.text)}", - ), - ); - - if (result.statusCode == 403) { - return; - } - - if (result.statusCode != 200) { - throw Error(); - } - - await collection.create(body: { - 'name': nameController.text, - 'url': url, - 'password': passwordController.text, - 'user': AppEngine.engine.pb.authStore.record!.id, - }); - - setState(() { - _collectionItems = collection.getList(); - }); - - if (context.mounted) { - Navigator.pop(context); - _collectionItems = collection.getList(); - } - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'Error: ${(e is ClientException) ? e.response[e.response.keys.first] : e}')), - ); - } - } - }, - child: const Text('Add Proxy'), - ), - const SizedBox(height: 16), - ], - ), - ); - }, - ), - ); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Proxy Settings'), - ), - body: FutureBuilder>( - future: _collectionItems, - builder: (context, snapshot) { - if (snapshot.hasError) { - return Center(child: Text('Error: ${snapshot.error}')); - } - - if (!snapshot.hasData) { - return const Center(child: CircularProgressIndicator()); - } - - final items = snapshot.data!.items; - - return ListView.builder( - itemCount: items.length, - itemBuilder: (context, index) { - final item = items[index]; - return ListTile( - title: Text(item.data['name']), - subtitle: Text(item.data['url']), - trailing: IconButton( - icon: const Icon(Icons.delete), - onPressed: () async { - try { - await collection.delete(item.id); - setState(() { - _collectionItems = collection.getList(); - }); - if (context.mounted && mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Proxy deleted')), - ); - } - } catch (e) { - if (mounted && context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Error: $e')), - ); - } - } - }, - ), - ); - }, - ); - }, - ), - floatingActionButton: FloatingActionButton( - onPressed: () => _showAddProxySheet(context), - child: const Icon(Icons.add), - ), - ); - } -} diff --git a/lib/features/settings/screen/security_screen.dart b/lib/features/settings/screen/security_screen.dart deleted file mode 100644 index 82b2b61..0000000 --- a/lib/features/settings/screen/security_screen.dart +++ /dev/null @@ -1,251 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:madari_client/engine/engine.dart'; -import 'package:pocketbase/pocketbase.dart'; - -class SecurityScreen extends StatefulWidget { - const SecurityScreen({super.key}); - - @override - State createState() => _SecurityScreenState(); -} - -class _SecurityScreenState extends State { - Future _showChangePasswordDialog() async { - return showDialog( - context: context, - builder: (context) => const ChangeDialog(), - ); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Security'), - ), - body: ListView( - children: [ - ListTile( - leading: const Icon(Icons.lock), - title: const Text('Change Password'), - subtitle: const Text('Update your account password'), - onTap: _showChangePasswordDialog, - ), - const Divider(), - const ListTile( - leading: Icon(Icons.security), - title: Text('Two-Factor Authentication'), - subtitle: Text('Coming soon'), - enabled: false, - ), - const Divider(), - const ListTile( - leading: Icon(Icons.history), - title: Text('Login History'), - subtitle: Text('Coming soon'), - enabled: false, - ), - ], - ), - ); - } -} - -class ChangeDialog extends StatefulWidget { - const ChangeDialog({ - super.key, - }); - - @override - State createState() => _ChangeDialogState(); -} - -class _ChangeDialogState extends State { - @override - void dispose() { - _currentPasswordController.dispose(); - _newPasswordController.dispose(); - _confirmPasswordController.dispose(); - super.dispose(); - } - - PocketBase get pocketBase => AppEngine.engine.pb; - final _formKey = GlobalKey(); - final _currentPasswordController = TextEditingController(); - final _newPasswordController = TextEditingController(); - final _confirmPasswordController = TextEditingController(); - bool _isLoading = false; - bool _obscureCurrentPassword = true; - bool _obscureNewPassword = true; - bool _obscureConfirmPassword = true; - - Future _changePassword() async { - if (!_formKey.currentState!.validate()) return; - - setState(() => _isLoading = true); - - try { - final userId = pocketBase.authStore.record!.id; - - // First verify the current password - await pocketBase.collection('users').authWithPassword( - pocketBase.authStore.record!.getStringValue("email"), - _currentPasswordController.text, - ); - - // If verification successful, update the password - await pocketBase.collection('users').update( - userId, - body: { - 'oldPassword': _currentPasswordController.text, - 'password': _newPasswordController.text, - 'passwordConfirm': _confirmPasswordController.text, - }, - ); - - // Clear the form - _currentPasswordController.clear(); - _newPasswordController.clear(); - _confirmPasswordController.clear(); - - // Close the dialog - if (mounted) Navigator.pop(context); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Password changed successfully'), - backgroundColor: Colors.green, - ), - ); - } - } catch (e) { - if (context.mounted && mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Error: ${e.toString()}'), - backgroundColor: Colors.red, - ), - ); - } - } finally { - if (mounted) { - setState(() => _isLoading = false); - } - } - } - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: const Text('Change Password'), - content: Form( - key: _formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextFormField( - controller: _currentPasswordController, - obscureText: _obscureCurrentPassword, - decoration: InputDecoration( - labelText: 'Current Password', - suffixIcon: IconButton( - icon: Icon( - _obscureCurrentPassword - ? Icons.visibility_off - : Icons.visibility, - ), - onPressed: () { - setState(() { - _obscureCurrentPassword = !_obscureCurrentPassword; - }); - }, - ), - ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter your current password'; - } - return null; - }, - ), - const SizedBox(height: 16), - TextFormField( - controller: _newPasswordController, - obscureText: _obscureNewPassword, - decoration: InputDecoration( - labelText: 'New Password', - suffixIcon: IconButton( - icon: Icon( - _obscureNewPassword - ? Icons.visibility_off - : Icons.visibility, - ), - onPressed: () { - setState(() { - _obscureNewPassword = !_obscureNewPassword; - }); - }, - ), - ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter a new password'; - } - if (value.length < 8) { - return 'Password must be at least 8 characters'; - } - return null; - }, - ), - const SizedBox(height: 16), - TextFormField( - controller: _confirmPasswordController, - obscureText: _obscureConfirmPassword, - decoration: InputDecoration( - labelText: 'Confirm New Password', - suffixIcon: IconButton( - icon: Icon( - _obscureConfirmPassword - ? Icons.visibility_off - : Icons.visibility, - ), - onPressed: () { - setState(() { - _obscureConfirmPassword = !_obscureConfirmPassword; - }); - }, - ), - ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please confirm your new password'; - } - if (value != _newPasswordController.text) { - return 'Passwords do not match'; - } - return null; - }, - ), - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), - ), - ElevatedButton( - onPressed: _isLoading ? null : _changePassword, - child: _isLoading - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Text('Change Password'), - ), - ], - ); - } -} diff --git a/lib/features/settings/screen/trakt_integration_screen.dart b/lib/features/settings/screen/trakt_integration_screen.dart deleted file mode 100644 index b027ddd..0000000 --- a/lib/features/settings/screen/trakt_integration_screen.dart +++ /dev/null @@ -1,324 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:madari_client/engine/engine.dart'; -import 'package:url_launcher/url_launcher.dart'; - -import '../../../utils/auth_refresh.dart'; - -class TraktIntegration extends StatefulWidget { - const TraktIntegration({ - super.key, - }); - - @override - State createState() => _TraktIntegrationState(); -} - -class _TraktIntegrationState extends State { - final pb = AppEngine.engine.pb; - bool isLoggedIn = false; - List selectedLists = []; - List availableLists = [...traktCategories]; - - @override - void initState() { - super.initState(); - checkIsLoggedIn(); - _loadSelectedCategories(); - } - - checkIsLoggedIn() { - final traktToken = pb.authStore.record!.getStringValue("trakt_token"); - - setState(() { - isLoggedIn = traktToken != ""; - }); - } - - void _loadSelectedCategories() async { - final record = pb.authStore.record!; - final config = record.get("config") ?? {}; - final savedCategories = - config["selected_categories"] as List? ?? []; - - setState(() { - selectedLists = traktCategories - .where((category) => savedCategories.contains(category.key)) - .toList(); - availableLists = traktCategories - .where((category) => !savedCategories.contains(category.key)) - .toList(); - }); - } - - void _saveSelectedCategories() async { - final record = pb.authStore.record!; - final config = record.get("config") ?? {}; - - config["selected_categories"] = - selectedLists.map((category) => category.key).toList(); - - await pb.collection('users').update( - record.id, - body: { - "config": config, - }, - ); - - await refreshAuth(); - } - - // Remove a category - void _removeCategory(TraktCategories category) { - setState(() { - selectedLists.remove(category); - availableLists.add(category); - }); - _saveSelectedCategories(); - } - - // Add a category - void _addCategory(TraktCategories category) { - setState(() { - availableLists.remove(category); - selectedLists.add(category); - }); - _saveSelectedCategories(); - } - - removeAccount() async { - final record = pb.authStore.record!; - record.set("trakt_token", ""); - - pb.collection('users').update( - record.id, - body: record.toJson(), - ); - - await refreshAuth(); - } - - loginWithTrakt() async { - await pb.collection("users").authWithOAuth2( - "oidc", - (url) async { - final newUrl = Uri.parse( - url.toString().replaceFirst( - "scope=openid&", - "", - ), - ); - await launchUrl(newUrl); - }, - scopes: ["openid"], - ); - - await refreshAuth(); - - checkIsLoggedIn(); - } - - Future _showAddCategoryDialog() async { - return showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - title: const Text( - "Add Category", - style: TextStyle(fontWeight: FontWeight.bold), - ), - content: SizedBox( - width: double.maxFinite, - child: ListView.builder( - shrinkWrap: true, - itemCount: availableLists.length, - itemBuilder: (context, index) { - final category = availableLists[index]; - return ListTile( - title: Text( - category.title, - style: const TextStyle(fontSize: 16), - ), - trailing: const Icon(Icons.add, color: Colors.blue), - onTap: () { - _addCategory(category); - Navigator.of(context).pop(); - }, - ); - }, - ), - ), - actions: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: const Text( - "Close", - style: TextStyle(color: Colors.red), - ), - ), - ], - ); - }, - ); - } - - void _onReorder(int oldIndex, int newIndex) { - setState(() { - if (newIndex > oldIndex) { - newIndex -= 1; - } - final TraktCategories item = selectedLists.removeAt(oldIndex); - selectedLists.insert(newIndex, item); - }); - _saveSelectedCategories(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text( - "Trakt Integration", - style: TextStyle(fontWeight: FontWeight.bold), - ), - centerTitle: true, - elevation: 0, - ), - body: Padding( - padding: const EdgeInsets.all(16.0), - child: Padding( - padding: const EdgeInsets.only( - bottom: 16.0, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (isLoggedIn) - ElevatedButton( - onPressed: () async { - await removeAccount(); - setState(() { - isLoggedIn = false; - }); - }, - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: const Text( - "Disconnect Account", - ), - ) - else - ElevatedButton( - onPressed: () { - loginWithTrakt(); - }, - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: const Text( - "Login with Trakt", - ), - ), - const SizedBox(height: 20), - if (isLoggedIn) ...[ - const Text( - "Selected Categories to show in home", - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 10), - Expanded( - child: ReorderableListView.builder( - itemCount: selectedLists.length, - onReorder: _onReorder, - itemBuilder: (context, index) { - final category = selectedLists[index]; - return Card( - key: ValueKey(category.key), - elevation: 4, - margin: const EdgeInsets.symmetric(vertical: 8), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - child: ListTile( - title: Text( - category.title, - style: const TextStyle(fontSize: 16), - ), - trailing: IconButton( - icon: const Icon(Icons.delete), - onPressed: () => _removeCategory(category), - ), - ), - ); - }, - ), - ), - const SizedBox(height: 20), - if (availableLists.isNotEmpty) - ElevatedButton( - onPressed: _showAddCategoryDialog, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.green.shade600, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: const Text( - "Add Category", - style: TextStyle(fontSize: 16, color: Colors.white), - ), - ), - ], - ], - ), - ), - ), - ); - } -} - -List traktCategories = [ - TraktCategories( - title: "Up Next - Trakt", - key: "up_next_series", - ), - TraktCategories( - title: "Continue watching", - key: "continue_watching", - ), - TraktCategories( - title: "Upcoming Schedule", - key: "upcoming_schedule", - ), - TraktCategories( - title: "Watchlist", - key: "watchlist", - ), - TraktCategories( - title: "Show Recommendations", - key: "show_recommendations", - ), - TraktCategories( - title: "Movie Recommendations", - key: "movie_recommendations", - ), -]; - -class TraktCategories { - final String title; - final String key; - - TraktCategories({ - required this.title, - required this.key, - }); -} diff --git a/lib/features/settings/service/account_profile_service.dart b/lib/features/settings/service/account_profile_service.dart new file mode 100644 index 0000000..309714c --- /dev/null +++ b/lib/features/settings/service/account_profile_service.dart @@ -0,0 +1,111 @@ +import 'dart:typed_data'; + +import 'package:http/http.dart'; +import 'package:logging/logging.dart'; +import 'package:pocketbase/pocketbase.dart'; + +import '../../pocketbase/service/pocketbase.service.dart'; + +class AccountProfileService { + static final AccountProfileService instance = + AccountProfileService._internal(); + final _logger = Logger('AccountProfileService'); + + AccountProfileService._internal(); + + Future createProfile({ + required String name, + required bool canSearch, + Uint8List? profileImage, + }) async { + try { + final formData = { + 'name': name, + 'can_search': canSearch, + 'user': AppPocketBaseService.instance.pb.authStore.record!.id, + }; + + final record = await AppPocketBaseService.instance.pb + .collection('account_profile') + .create( + body: formData, + files: [ + if (profileImage != null) + MultipartFile.fromBytes( + 'profile_image', + profileImage, + filename: 'profile_image.jpg', + ) + ], + ); + + _logger.info('Profile created successfully: ${record.id}'); + return record; + } catch (e, stack) { + _logger.warning('Error creating profile: $e', e, stack); + rethrow; + } + } + + Future updateProfile({ + required String id, + String? name, + bool? canSearch, + Uint8List? profileImage, + }) async { + try { + final formData = {}; + + if (name != null) formData['name'] = name; + if (canSearch != null) formData['can_search'] = canSearch; + + final record = await AppPocketBaseService.instance.pb + .collection('account_profile') + .update( + id, + body: formData, + files: [ + if (profileImage != null) + MultipartFile.fromBytes( + 'profile_image', + profileImage, + filename: 'profile_image.jpg', + ), + ], + ); + + _logger.info('Profile updated successfully: ${record.id}'); + return record; + } catch (e) { + _logger.warning('Error updating profile: $e'); + rethrow; + } + } + + Future> getProfiles() async { + try { + final records = await AppPocketBaseService.instance.pb + .collection('account_profile') + .getFullList(); + + _logger.info('Retrieved ${records.length} profiles'); + return records; + } catch (e) { + _logger.warning('Error fetching profiles: $e'); + rethrow; + } + } + + Future deleteProfile(String id) async { + try { + await AppPocketBaseService.instance.pb + .collection('account_profile') + .delete(id); + + _logger.info('Profile deleted successfully: $id'); + } catch (e) { + _logger.warning('Error deleting profile: $e'); + rethrow; + } + } +} diff --git a/lib/features/settings/service/external_players.dart b/lib/features/settings/service/external_players.dart new file mode 100644 index 0000000..8225006 --- /dev/null +++ b/lib/features/settings/service/external_players.dart @@ -0,0 +1,62 @@ +import '../model/external_media_player.dart'; + +final Map> externalPlayers = { + "android": [ + const ExternalMediaPlayer(id: "", name: "App chooser"), + const ExternalMediaPlayer(id: "org.videolan.vlc", name: "VLC"), + const ExternalMediaPlayer( + id: "com.mxtech.videoplayer.ad", name: "MX Player"), + const ExternalMediaPlayer( + id: "com.mxtech.videoplayer.pro", + name: "MX Player Pro", + ), + const ExternalMediaPlayer( + id: "com.brouken.player", + name: "JustPlayer", + ), + const ExternalMediaPlayer( + id: "xyz.skybox.player", + name: "Skybox", + ), + ], + "ios": [ + const ExternalMediaPlayer( + id: "open-vidhub", + name: "VidHub", + ), + const ExternalMediaPlayer( + id: "infuse", + name: "Infuse", + ), + const ExternalMediaPlayer( + id: "vlc", + name: "VLC", + ), + const ExternalMediaPlayer( + id: "outplayer", + name: "Outplayer", + ), + ], + "macos": [ + const ExternalMediaPlayer( + id: "open-vidhub", + name: "VidHub", + ), + const ExternalMediaPlayer( + id: "infuse", + name: "Infuse", + ), + const ExternalMediaPlayer( + id: "iina", + name: "IINA", + ), + const ExternalMediaPlayer( + id: "omniplayer", + name: "OmniPlayer", + ), + const ExternalMediaPlayer( + id: "nplayer-mac", + name: "nPlayer", + ), + ] +}; diff --git a/lib/features/settings/service/playback_setting_service.dart b/lib/features/settings/service/playback_setting_service.dart new file mode 100644 index 0000000..9b4ecc8 --- /dev/null +++ b/lib/features/settings/service/playback_setting_service.dart @@ -0,0 +1,86 @@ +import 'dart:convert'; + +import 'package:cached_query_flutter/cached_query_flutter.dart'; +import 'package:flutter/services.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../pocketbase/service/pocketbase.service.dart'; +import '../model/playback_settings_model.dart'; + +class PlaybackSettingsService { + static const _localSettingsKey = 'playback_settings_local'; + static final PlaybackSettingsService instance = PlaybackSettingsService._(); + + PlaybackSettingsService._(); + + PlaybackSettings? _cachedSettings; + Map? _cachedLanguages; + + Future> getLanguages() async { + if (_cachedLanguages != null) return _cachedLanguages!; + + final String jsonString = + await rootBundle.loadString('assets/data/languages.json'); + _cachedLanguages = Map.from(json.decode(jsonString)); + return _cachedLanguages!; + } + + Future saveSettings(PlaybackSettings settings) async { + final result = Query( + key: "video_settings", + queryFn: () { + return getSettings(); + }, + ); + + result.invalidateQuery(); + + final prefs = await SharedPreferences.getInstance(); + final localSettings = { + 'disableHardwareAcceleration': settings.disableHardwareAcceleration, + 'externalPlayer': settings.externalPlayer, + 'selectedExternalPlayer': settings.selectedExternalPlayer, + }; + + await prefs.setString(_localSettingsKey, json.encode(localSettings)); + await AppPocketBaseService.instance.pb.collection('users').update( + AppPocketBaseService.instance.pb.authStore.model.id, + body: {'playback_v2': settings.toJson()}, + ); + + _cachedSettings = settings; + } + + Future getSettings() async { + if (_cachedSettings != null) return _cachedSettings!; + + final prefs = await SharedPreferences.getInstance(); + final localSettingsStr = prefs.getString(_localSettingsKey); + final localSettings = + localSettingsStr != null ? json.decode(localSettingsStr) : {}; + + final record = + await AppPocketBaseService.instance.pb.collection('users').getOne( + AppPocketBaseService.instance.pb.authStore.record!.id, + ); + + final serverSettings = PlaybackSettings.fromJson( + record.data['playback_v2'] ?? {}, + ); + + _cachedSettings = PlaybackSettings( + autoPlay: serverSettings.autoPlay, + playbackSpeed: serverSettings.playbackSpeed, + defaultAudioTrack: serverSettings.defaultAudioTrack, + defaultSubtitleTrack: serverSettings.defaultSubtitleTrack, + subtitleColor: serverSettings.subtitleColor, + fontSize: serverSettings.fontSize, + disableHardwareAcceleration: + localSettings['disableHardwareAcceleration'] ?? false, + externalPlayer: localSettings['externalPlayer'] ?? false, + selectedExternalPlayer: localSettings['selectedExternalPlayer'], + ); + + return _cachedSettings!; + } +} diff --git a/lib/features/settings/service/selected_profile.dart b/lib/features/settings/service/selected_profile.dart new file mode 100644 index 0000000..761caed --- /dev/null +++ b/lib/features/settings/service/selected_profile.dart @@ -0,0 +1,51 @@ +import 'package:logging/logging.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class SelectedProfileService { + static final SelectedProfileService instance = + SelectedProfileService._internal(); + final _logger = Logger('SelectedProfileService'); + + static const String _selectedProfileKey = 'selected_profile_id'; + final _selectedProfileSubject = BehaviorSubject(); + + SharedPreferences? _prefs; + + SelectedProfileService._internal(); + + Future initialize() async { + try { + _prefs = await SharedPreferences.getInstance(); + final storedId = _prefs?.getString(_selectedProfileKey); + _selectedProfileSubject.add(storedId); + _logger.info('Initialized with stored profile ID: $storedId'); + } catch (e, stack) { + _logger.severe('Error initializing SelectedProfileService', e, stack); + rethrow; + } + } + + String? get selectedProfileId => _selectedProfileSubject.valueOrNull; + + Stream get selectedProfileStream => _selectedProfileSubject.stream; + + Future setSelectedProfile(String? profileId) async { + try { + if (profileId != null) { + await _prefs?.setString(_selectedProfileKey, profileId); + } else { + await _prefs?.remove(_selectedProfileKey); + } + _selectedProfileSubject.add(profileId); + _logger.info('Selected profile updated: $profileId'); + } catch (e, stack) { + _logger.severe('Error setting selected profile', e, stack); + rethrow; + } + } + + void dispose() { + _selectedProfileSubject.close(); + } +} diff --git a/lib/features/settings/types/connection.dart b/lib/features/settings/types/connection.dart deleted file mode 100644 index 9fdc4c2..0000000 --- a/lib/features/settings/types/connection.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; -import 'package:pocketbase/pocketbase.dart'; - -part 'connection.g.dart'; - -@JsonSerializable() -class Connection { - final String id; - final String title; - final String type; - final dynamic config; - - const Connection({ - required this.id, - required this.title, - required this.type, - required this.config, - }); - - factory Connection.fromJson(Map json) => - _$ConnectionFromJson(json); - - factory Connection.fromRecord(RecordModel record) => Connection.fromJson( - record.toJson(), - ); - - Map toJson() => _$ConnectionToJson(this); -} - -class ConnectionType { - final String id; - final String title; - - ConnectionType({ - required this.id, - required this.title, - }); -} diff --git a/lib/features/settings/types/user_profile.dart b/lib/features/settings/types/user_profile.dart deleted file mode 100644 index ca47fde..0000000 --- a/lib/features/settings/types/user_profile.dart +++ /dev/null @@ -1,30 +0,0 @@ -class UserProfile { - final String id; - String fullName; - String email; - String? avatar; - - UserProfile({ - required this.id, - required this.fullName, - required this.email, - this.avatar, - }); - - factory UserProfile.fromJson(Map json) { - return UserProfile( - id: json['id'], - fullName: json['name'] ?? '', - email: json['email'] ?? '', - avatar: json['avatar'], - ); - } - - Map toJson() { - return { - 'fullName': fullName, - 'email': email, - if (avatar != null) 'avatar': avatar, - }; - } -} diff --git a/lib/features/settings/widget/language_selector.dart b/lib/features/settings/widget/language_selector.dart new file mode 100644 index 0000000..36ff346 --- /dev/null +++ b/lib/features/settings/widget/language_selector.dart @@ -0,0 +1,99 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class LanguageSelector extends StatefulWidget { + final String? initialValue; + final ValueChanged onChanged; + + const LanguageSelector({ + super.key, + this.initialValue, + required this.onChanged, + }); + + @override + State createState() => _LanguageSelectorState(); +} + +class _LanguageSelectorState extends State { + List> _languages = []; + String? _selectedLanguage; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _selectedLanguage = widget.initialValue; + _loadLanguages(); + } + + Future _loadLanguages() async { + try { + final String jsonString = await rootBundle.loadString( + 'assets/data/tmdb_language.json', + ); + final List jsonList = json.decode(jsonString); + setState(() { + _languages = List>.from(jsonList); + _isLoading = false; + }); + } catch (e) { + debugPrint('Error loading languages: $e'); + setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + return DropdownButtonFormField( + value: _selectedLanguage, + decoration: InputDecoration( + labelText: 'Language', + prefixIcon: const Icon(Icons.language), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline.withOpacity(0.5), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.primary, + width: 2, + ), + ), + filled: true, + fillColor: + Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3), + ), + items: _languages.map((language) { + return DropdownMenuItem( + value: language['iso_639_1'], + child: Text(language['english_name']), + ); + }).toList() + ..sort((a, b) => + (a.child as Text).data!.compareTo((b.child as Text).data!)), + onChanged: (String? value) { + setState(() => _selectedLanguage = value); + widget.onChanged(value); + }, + hint: const Text('Select your language'), + validator: (value) => value == null ? 'Please select a language' : null, + isExpanded: true, + ); + } +} diff --git a/lib/features/settings/widget/profile_dialog.dart b/lib/features/settings/widget/profile_dialog.dart new file mode 100644 index 0000000..f8e7572 --- /dev/null +++ b/lib/features/settings/widget/profile_dialog.dart @@ -0,0 +1,208 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:logging/logging.dart'; +import 'package:madari_client/features/common/utils/error_handler.dart'; +import 'package:pocketbase/pocketbase.dart'; + +import '../../pocketbase/service/pocketbase.service.dart'; +import '../service/account_profile_service.dart'; + +class ProfileDialog extends StatefulWidget { + final RecordModel? profile; + + const ProfileDialog({super.key, this.profile}); + + @override + State createState() => _ProfileDialogState(); +} + +class _ProfileDialogState extends State { + final _formKey = GlobalKey(); + final _logger = Logger('_ProfileDialogState'); + final _nameController = TextEditingController(); + final _profileService = AccountProfileService.instance; + + bool _canSearch = true; + Uint8List? _selectedImage; + bool _isLoading = false; + + @override + void initState() { + super.initState(); + if (widget.profile != null) { + _nameController.text = widget.profile!.getStringValue('name'); + _canSearch = widget.profile!.getBoolValue('can_search'); + } + } + + @override + void dispose() { + _nameController.dispose(); + super.dispose(); + } + + Future _pickImage() async { + try { + final picker = ImagePicker(); + final pickedImage = await picker.pickImage(source: ImageSource.gallery); + + if (pickedImage != null) { + final imageBytes = await pickedImage.readAsBytes(); + setState(() => _selectedImage = imageBytes); + } + } catch (e) { + _logger.warning('Error picking image: $e'); + } + } + + Future _saveProfile() async { + if (!_formKey.currentState!.validate()) return; + + setState(() => _isLoading = true); + + try { + if (widget.profile == null) { + await _profileService.createProfile( + name: _nameController.text, + canSearch: _canSearch, + profileImage: _selectedImage, + ); + } else { + await _profileService.updateProfile( + id: widget.profile!.id, + name: _nameController.text, + canSearch: _canSearch, + profileImage: _selectedImage, + ); + } + + if (mounted) { + Navigator.pop(context, true); + } + } on ClientException catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(getErrorMessage(e))), + ); + } + } finally { + setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Dialog( + child: Container( + constraints: const BoxConstraints(maxWidth: 600), + padding: const EdgeInsets.all(24), + child: SingleChildScrollView( + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + widget.profile == null ? 'Create Profile' : 'Edit Profile', + style: theme.textTheme.headlineSmall, + ), + const SizedBox(height: 24), + GestureDetector( + onTap: _pickImage, + child: CircleAvatar( + radius: 50, + backgroundImage: _selectedImage != null + ? MemoryImage(_selectedImage!) + : widget.profile + ?.getStringValue('profile_image') + .isNotEmpty ?? + false + ? NetworkImage( + AppPocketBaseService.instance.pb.files + .getUrl( + widget.profile!, + widget.profile! + .getStringValue('profile_image'), + ) + .toString(), + ) + : null, + child: _selectedImage == null && + (widget.profile + ?.getStringValue('profile_image') + .isEmpty ?? + true) + ? const Icon(Icons.camera_alt, size: 40) + : null, + ), + ), + const SizedBox(height: 16), + TextButton( + onPressed: _pickImage, + child: const Text( + 'Change Profile Picture (Do not upload any person picture)', + ), + ), + const SizedBox(height: 24), + TextFormField( + controller: _nameController, + decoration: InputDecoration( + labelText: 'Profile Name', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a profile name'; + } + return null; + }, + autofocus: true, + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 16), + SwitchListTile( + title: const Text('Enable Search'), + subtitle: const Text( + 'Allow this profile to search and discover', + ), + value: _canSearch, + onChanged: (value) => setState(() => _canSearch = value), + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: + _isLoading ? null : () => Navigator.pop(context), + child: const Text('Cancel'), + ), + const SizedBox(width: 16), + FilledButton( + onPressed: _isLoading ? null : _saveProfile, + child: _isLoading + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(), + ) + : Text(widget.profile == null ? 'Create' : 'Save'), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/settings/widget/region_selector.dart b/lib/features/settings/widget/region_selector.dart new file mode 100644 index 0000000..f57854b --- /dev/null +++ b/lib/features/settings/widget/region_selector.dart @@ -0,0 +1,99 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class RegionSelector extends StatefulWidget { + final String? initialValue; + final ValueChanged onChanged; + + const RegionSelector({ + super.key, + this.initialValue, + required this.onChanged, + }); + + @override + State createState() => _RegionSelectorState(); +} + +class _RegionSelectorState extends State { + Map _regions = {}; + String? _selectedRegion; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _selectedRegion = widget.initialValue; + _loadRegions(); + } + + Future _loadRegions() async { + try { + final String jsonString = + await rootBundle.loadString('assets/data/regions.json'); + final Map jsonMap = json.decode(jsonString); + setState(() { + _regions = Map.from(jsonMap); + _isLoading = false; + }); + } catch (e) { + debugPrint('Error loading regions: $e'); + setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + return DropdownButtonFormField( + value: _selectedRegion, + decoration: InputDecoration( + labelText: 'Region', + prefixIcon: const Icon(Icons.location_on_outlined), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline.withOpacity(0.5), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.primary, + width: 2, + ), + ), + filled: true, + fillColor: + Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3), + ), + items: _regions.entries.map((entry) { + return DropdownMenuItem( + value: entry.key, + child: Text(entry.value), + ); + }).toList() + ..sort( + (a, b) => (a.child as Text).data!.compareTo((b.child as Text).data!), + ), + onChanged: (String? value) { + setState(() => _selectedRegion = value); + widget.onChanged(value); + }, + hint: const Text('Select your region'), + validator: (value) => value == null ? 'Please select a region' : null, + isExpanded: true, + ); + } +} diff --git a/lib/features/settings/widget/searchable_language_dropdown.dart b/lib/features/settings/widget/searchable_language_dropdown.dart new file mode 100644 index 0000000..fcc53d2 --- /dev/null +++ b/lib/features/settings/widget/searchable_language_dropdown.dart @@ -0,0 +1,157 @@ +import 'package:flutter/material.dart'; + +class SearchableLanguageDropdown extends StatefulWidget { + final Map languages; + final String value; + final String label; + final ValueChanged onChanged; + + const SearchableLanguageDropdown({ + super.key, + required this.languages, + required this.value, + required this.label, + required this.onChanged, + }); + + @override + State createState() => + _SearchableLanguageDropdownState(); +} + +class _SearchableLanguageDropdownState + extends State { + final TextEditingController _searchController = TextEditingController(); + final FocusNode _focusNode = FocusNode(); + bool _isOpen = false; + List> _filteredLanguages = []; + + @override + void initState() { + super.initState(); + _filteredLanguages = widget.languages.entries.toList(); + } + + @override + void dispose() { + _searchController.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + void _filterLanguages(String query) { + setState(() { + _filteredLanguages = widget.languages.entries + .where((entry) => + entry.value.toLowerCase().contains(query.toLowerCase()) || + entry.key.toLowerCase().contains(query.toLowerCase())) + .toList(); + }); + } + + void _closeDropdown() { + setState(() { + _isOpen = false; + _searchController.clear(); + _filteredLanguages = widget.languages.entries.toList(); + }); + } + + void _toggleDropdown() { + setState(() { + _isOpen = !_isOpen; + if (!_isOpen) { + _searchController.clear(); + _filteredLanguages = widget.languages.entries.toList(); + } + }); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + onTap: _toggleDropdown, + child: InputDecorator( + decoration: InputDecoration( + labelText: widget.label, + border: const OutlineInputBorder(), + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + suffixIcon: Icon( + _isOpen ? Icons.arrow_drop_up : Icons.arrow_drop_down, + ), + ), + child: Text( + widget.languages[widget.value] ?? 'Select language', + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ), + if (_isOpen) + Card( + elevation: 8, + margin: const EdgeInsets.only(top: 4), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: TextField( + controller: _searchController, + focusNode: _focusNode, + decoration: InputDecoration( + hintText: 'Search language...', + prefixIcon: const Icon(Icons.search), + border: const OutlineInputBorder(), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + suffixIcon: _searchController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + _filterLanguages(''); + }, + ) + : null, + ), + onChanged: _filterLanguages, + ), + ), + SizedBox( + height: 250, + child: ListView.builder( + padding: EdgeInsets.zero, + itemCount: _filteredLanguages.length, + itemBuilder: (context, index) { + final entry = _filteredLanguages[index]; + final isSelected = entry.key == widget.value; + + return ListTile( + dense: true, + title: Text(entry.value), + subtitle: Text(entry.key), + selected: isSelected, + tileColor: isSelected + ? Theme.of(context).colorScheme.primaryContainer + : null, + onTap: () { + widget.onChanged(entry.key); + _closeDropdown(); + }, + ); + }, + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/features/settings/widget/setting_wrapper.dart b/lib/features/settings/widget/setting_wrapper.dart new file mode 100644 index 0000000..04f8d1d --- /dev/null +++ b/lib/features/settings/widget/setting_wrapper.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +class SettingWrapper extends StatelessWidget { + final Widget child; + + const SettingWrapper({ + super.key, + required this.child, + }); + + @override + Widget build(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + final horizontalPadding = switch (screenWidth) { + > 1024 => screenWidth * 0.2, + > 600 => 48.0, + _ => 16.0, + }; + final isSmallScreen = screenWidth <= 600; + + return Padding( + padding: EdgeInsets.symmetric( + horizontal: horizontalPadding, + vertical: isSmallScreen ? 16.0 : 24.0, + ), + child: child, + ); + } +} diff --git a/lib/features/streamio_addons/extension/query_extension.dart b/lib/features/streamio_addons/extension/query_extension.dart new file mode 100644 index 0000000..18a0486 --- /dev/null +++ b/lib/features/streamio_addons/extension/query_extension.dart @@ -0,0 +1,15 @@ +import 'package:cached_query_flutter/cached_query_flutter.dart'; + +extension QueryExtension on Query { + Future queryFn() async { + final result = await stream + .where((state) => state.status != QueryStatus.loading) + .first; + + if (result.error != null) { + throw result.error!; + } + + return result.data!; + } +} diff --git a/lib/features/connections/types/stremio/stremio_base.types.dart b/lib/features/streamio_addons/models/stremio_base_types.dart similarity index 80% rename from lib/features/connections/types/stremio/stremio_base.types.dart rename to lib/features/streamio_addons/models/stremio_base_types.dart index a45b06a..0cfe6a8 100644 --- a/lib/features/connections/types/stremio/stremio_base.types.dart +++ b/lib/features/streamio_addons/models/stremio_base_types.dart @@ -1,9 +1,7 @@ import 'package:json_annotation/json_annotation.dart'; import 'package:pocketbase/pocketbase.dart'; -import '../../service/base_connection_service.dart'; - -part 'stremio_base.types.g.dart'; +part 'stremio_base_types.g.dart'; class ResourceConverter implements JsonConverter { const ResourceConverter(); @@ -53,14 +51,34 @@ class ResourceObject { Map toJson() => _$ResourceObjectToJson(this); } +@JsonSerializable() +class ManifestFeatures { + String id; + + ManifestFeatures({ + required this.id, + }); + + factory ManifestFeatures.fromJson(Map json) { + return _$ManifestFeaturesFromJson(json); + } + + Map toJson() => _$ManifestFeaturesToJson(this); +} + @JsonSerializable() class StremioManifest { final String id; final String name; + final String? description; final List? catalogs; final List? idPrefixes; final String? icon; + final String? logo; final List? types; + String? manifestUrl; + final String? manifestVersion; + final List? features; @ResourceConverter() final List? resources; @@ -69,16 +87,21 @@ class StremioManifest { required this.id, required this.name, required this.catalogs, + this.description, this.idPrefixes, this.resources, this.icon, + this.logo, + this.features = const [], this.types, + this.manifestVersion, }); - factory StremioManifest.fromRecord(RecordModel record) => - StremioManifest.fromJson(record.toJson()); + factory StremioManifest.fromRecord(RecordModel record, String url) => + StremioManifest.fromJson(record.toJson(), url); - factory StremioManifest.fromJson(Map json) { + factory StremioManifest.fromJson( + Map json, String manifestUrl) { final result = json['resources'] as List; final resources = []; @@ -95,7 +118,10 @@ class StremioManifest { json['resources'] = resources; - return _$StremioManifestFromJson(json); + final manifest = _$StremioManifestFromJson(json); + manifest.manifestUrl = manifestUrl; + + return manifest; } Map toJson() => _$StremioManifestToJson(this); @@ -107,12 +133,16 @@ class StremioManifestCatalog { String id; String? name; final List? extra; + final List? extraRequired; + final List? extraSupported; StremioManifestCatalog({ required this.id, required this.type, this.extra, this.name, + this.extraRequired, + this.extraSupported, }); factory StremioManifestCatalog.fromRecord(RecordModel record) => @@ -151,13 +181,9 @@ class StremioManifestCatalogExtra { @JsonSerializable() class StremioConfig { List addons; - List? movieIframe; - List? seriesIframe; StremioConfig({ required this.addons, - required this.movieIframe, - required this.seriesIframe, }); factory StremioConfig.fromRecord(RecordModel record) => @@ -226,7 +252,7 @@ class StrmioMeta { } @JsonSerializable() -class Meta extends LibraryItem { +class Meta { @JsonKey(name: "imdb_id") final String? imdbId; @JsonKey(name: "name") @@ -245,6 +271,8 @@ class Meta extends LibraryItem { final List? genre; @JsonKey(name: "imdbRating") final dynamic imdbRating_; + @JsonKey(name: "tmdbRating") + final dynamic tmdbRating_; @JsonKey(name: "poster") String? poster; @JsonKey(name: "released") @@ -311,6 +339,10 @@ class Meta extends LibraryItem { return (imdbRating_ ?? "").toString(); } + String get tmdbRating { + return (tmdbRating_ ?? "").toString(); + } + String get releaseInfo { return (releaseInfo_).toString(); } @@ -328,6 +360,7 @@ class Meta extends LibraryItem { Meta({ this.imdbId, this.name, + this.tmdbRating_, this.popularities, required this.type, this.cast, @@ -365,7 +398,7 @@ class Meta extends LibraryItem { this.dvdRelease, this.progress, this.traktProgressId, - }) : super(id: id); + }); Meta copyWith({ String? imdbId, @@ -410,6 +443,7 @@ class Meta extends LibraryItem { double? progress, bool? forceRegular, int? traktProgressId, + dynamic tmdbRating, }) => Meta( imdbId: imdbId ?? this.imdbId, @@ -451,6 +485,7 @@ class Meta extends LibraryItem { language: language ?? this.language, dvdRelease: dvdRelease ?? this.dvdRelease, progress: progress ?? this.progress, + tmdbRating_: tmdbRating ?? tmdbRating_, ); factory Meta.fromJson(Map json) { @@ -665,7 +700,7 @@ class Video { @JsonKey(name: "name") String? name; @JsonKey(name: "season") - final int season; + final int? season; @JsonKey(name: "number") final int? number; @JsonKey(name: "firstAired") @@ -690,6 +725,8 @@ class Video { final int? moviedbId; double? progress; dynamic ids; + @JsonKey(name: "streams") + final List? streams; Video({ this.name, @@ -699,6 +736,7 @@ class Video { this.tvdbId, this.overview, this.thumbnail, + this.streams, required this.id, this.released, this.episode, @@ -811,3 +849,118 @@ class VideoStream { Map toJson() => _$VideoStreamToJson(this); } + +class StreamInfo { + final String? resolution; + final String? quality; + final String? codec; + final String? audio; + final String? region; + final String? container; + final bool unrated; + final double? size; + + StreamInfo({ + this.resolution, + this.quality, + this.codec, + this.audio, + this.region, + this.container, + this.unrated = false, + this.size, + }); +} + +class StreamParser { + static final _resolutionRegex = RegExp( + '(2160p|1080p|720p|480p|360p|4k|uhd)', + caseSensitive: false, + ); + + static final _qualityRegex = RegExp( + '(bluray|bdrip|brrip|webrip|webdl|web-dl|hdrip|dvdrip|hdtv)', + caseSensitive: false, + ); + + static final _codecRegex = RegExp( + '(x264|x265|h264|h265|xvid|hevc|mpeg2|mpeg4|vc1|vp8|vp9|av1)', + caseSensitive: false, + ); + + static final _audioRegex = RegExp( + '(aac|ac3|dts|dtshd|truehd|dd5\\.1|dd7\\.1|atmos|dts-hd|eac3)', + caseSensitive: false, + ); + + static final _regionRegex = RegExp( + '(eur|usa|uk|fr|es|de|it|ru|cn|jp|kor)', + caseSensitive: false, + ); + + static final _containerRegex = RegExp( + '(mkv|mp4|avi|wmv|mov|flv|mpg|mpeg)', + caseSensitive: false, + ); + + static final _unratedRegex = RegExp( + '(unrated|uncensored)', + caseSensitive: false, + ); + + static final _sizeRegex = RegExp( + '(\\d+(?:\\.\\d+)?(?:GB|MB|TB))', + caseSensitive: false, + ); + + static String getSizeCategory(double? sizeInMB) { + if (sizeInMB == null) return 'Unknown'; + if (sizeInMB < 500) return '0-500MB'; + if (sizeInMB < 1500) return '500MB-1.5GB'; + if (sizeInMB < 3000) return '1.5GB-3GB'; + if (sizeInMB < 6000) return '3GB-6GB'; + if (sizeInMB < 12000) return '6GB-12GB'; + if (sizeInMB < 20000) return '12GB-20GB'; + return '20GB+'; + } + + static double? parseSize(String? sizeStr) { + if (sizeStr == null) return null; + + final match = _sizeRegex.firstMatch(sizeStr.toUpperCase()); + if (match == null) return null; + + final value = + double.tryParse(match.group(1)?.replaceAll(RegExp(r'[A-Z]'), '') ?? ''); + if (value == null) return null; + + if (sizeStr.toUpperCase().contains('GB')) { + return value * 1024; + } else if (sizeStr.toUpperCase().contains('TB')) { + return value * 1024 * 1024; + } + return value; + } + + static StreamInfo parseStreamName(String name) { + final resMatch = _resolutionRegex.firstMatch(name); + final qualMatch = _qualityRegex.firstMatch(name); + final codecMatch = _codecRegex.firstMatch(name); + final audioMatch = _audioRegex.firstMatch(name); + final regionMatch = _regionRegex.firstMatch(name); + final containerMatch = _containerRegex.firstMatch(name); + final unratedMatch = _unratedRegex.hasMatch(name); + final sizeMatch = _sizeRegex.firstMatch(name); + + return StreamInfo( + resolution: resMatch?.group(1)?.toUpperCase(), + quality: qualMatch?.group(1)?.toUpperCase(), + codec: codecMatch?.group(1)?.toUpperCase(), + audio: audioMatch?.group(1)?.toUpperCase(), + region: regionMatch?.group(1)?.toUpperCase(), + container: containerMatch?.group(1)?.toUpperCase(), + unrated: unratedMatch, + size: parseSize(sizeMatch?.group(1)), + ); + } +} diff --git a/lib/features/streamio_addons/pages/stremio_addons_page.dart b/lib/features/streamio_addons/pages/stremio_addons_page.dart new file mode 100644 index 0000000..6573e68 --- /dev/null +++ b/lib/features/streamio_addons/pages/stremio_addons_page.dart @@ -0,0 +1,241 @@ +import 'package:flutter/material.dart'; + +import '../../settings/widget/setting_wrapper.dart'; +import '../models/stremio_base_types.dart'; +import '../service/stremio_addon_service.dart'; +import '../widget/add_addon_sheet.dart'; +import '../widget/stremio_addons_list.dart'; + +class StremioAddonsPage extends StatefulWidget { + final bool showHidden; + const StremioAddonsPage({ + super.key, + this.showHidden = false, + }); + + @override + State createState() => _StremioAddonsPageState(); +} + +class _StremioAddonsPageState extends State { + late final query = StremioAddonService.instance.getInstalledAddons( + enabledOnly: widget.showHidden != true, + ); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Stremio Addons'), + actions: [ + if (!widget.showHidden) + ElevatedButton.icon( + onPressed: () { + showModalBottomSheet( + context: context, + builder: (context) { + return const StremioAddonsPage( + showHidden: true, + ); + }, + ); + }, + icon: const Icon( + Icons.hide_image, + ), + label: const Text("Show disabled addons"), + ), + ], + ), + body: SettingWrapper( + child: StremioAddonsList( + query: query, + showHidden: widget.showHidden, + ), + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => _showAddAddonSheet(context), + icon: const Icon(Icons.add), + label: const Text('Add Addon'), + ), + ); + } + + void _showAddAddonSheet(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (context) => AddAddonSheet(onRefetch: () { + query.refetch(); + }), + ); + } +} + +class ManageAddonDialog extends StatelessWidget { + final StremioManifest addon; + final bool showHidden; + + const ManageAddonDialog({ + required this.addon, + super.key, + required this.showHidden, + }); + + @override + Widget build(BuildContext context) { + return Dialog( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 500), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + addon.name, + style: Theme.of(context).textTheme.titleLarge, + ), + if (addon.description != null) ...[ + const SizedBox(height: 8), + Text(addon.description!), + ], + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () => _disableAddon(context), + icon: const Icon(Icons.update_disabled), + label: const Text('Enable Addon'), + ), + ), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: () => _removeAddon(context), + icon: const Icon(Icons.delete), + label: const Text('Remove Addon'), + ), + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ), + ], + ), + ), + ), + ); + } + + Future _disableAddon(BuildContext context) async { + try { + if (addon.manifestUrl == null) { + throw Exception('Addon manifest URL is null'); + } + + await StremioAddonService.instance.toggleAddonState( + addon.manifestUrl!, + showHidden == true, + ); + + if (context.mounted) { + Navigator.of(context).pop(); + } + + StremioAddonService.instance.getInstalledAddons().refetch(); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to disable addon: $e')), + ); + } + } + } + + Future _removeAddon(BuildContext context) async { + final confirm = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Remove Addon'), + content: Text('Are you sure you want to remove ${addon.name}?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Remove'), + ), + ], + ), + ); + + if (confirm == true && context.mounted) { + try { + if (context.mounted) { + Navigator.of(context).pop(); + } + await StremioAddonService.instance.removeAddon(addon.manifestUrl!); + StremioAddonService.instance.getInstalledAddons().refetch(); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to remove addon: $e')), + ); + } + } + } + } +} + +class AddonListTile extends StatelessWidget { + final StremioManifest addon; + + const AddonListTile({ + required this.addon, + super.key, + }); + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16), + child: ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + leading: addon.logo != null + ? ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + addon.logo!, + width: 48, + height: 48, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => + const Icon(Icons.extension, size: 48), + ), + ) + : const Icon(Icons.extension, size: 48), + title: Text(addon.name), + subtitle: addon.description != null + ? Text( + addon.description!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ) + : null, + trailing: const Icon(Icons.chevron_right), + ), + ); + } +} diff --git a/lib/features/streamio_addons/service/stremio_addon_service.dart b/lib/features/streamio_addons/service/stremio_addon_service.dart new file mode 100644 index 0000000..469a1ed --- /dev/null +++ b/lib/features/streamio_addons/service/stremio_addon_service.dart @@ -0,0 +1,570 @@ +import 'dart:convert'; + +import 'package:cached_query_flutter/cached_query_flutter.dart'; +import 'package:http/http.dart' as http; +import 'package:logging/logging.dart'; +import 'package:madari_client/data/db.dart'; +import 'package:madari_client/features/streamio_addons/extension/query_extension.dart'; +import 'package:madari_client/utils/array-extension.dart'; + +import '../../pocketbase/service/pocketbase.service.dart'; +import '../../widgetter/plugins/stremio/models/cast_info.dart'; +import '../models/stremio_base_types.dart'; + +typedef OnStreamCallback = void Function( + List? items, + String? addonName, + Error?, +); + +class StremioAddonService { + final _logger = Logger('StremioAddonService'); + + static final StremioAddonService instance = StremioAddonService._internal(); + final _manifestQueryConfig = QueryConfig( + cacheDuration: const Duration(hours: 8), + ); + + StremioAddonService._internal(); + + Future getStreams( + Meta meta, { + OnStreamCallback? callback, + }) async { + _logger.fine('Fetching streams for item: ${meta.id}'); + final List streams = []; + + final List> promises = []; + + final addons = await getInstalledAddons(enabledOnly: true).queryFn(); + + for (final addon in addons) { + final future = Future.delayed(const Duration(seconds: 0), () async { + final addonManifest = addon; + for (final resource_ in (addonManifest.resources ?? [])) { + final resource = resource_ as ResourceObject; + + if (!doesAddonSupportStream(resource, addonManifest, meta)) { + _logger.finer( + 'Addon does not support stream: ${addonManifest.name}', + ); + continue; + } + + final url = + "${_getAddonBaseURL(addon.manifestUrl!)}/stream/${meta.type}/${Uri.encodeComponent(meta.currentVideo?.id ?? meta.imdbId ?? meta.id)}.json"; + + _logger.info("Loading streams from $url"); + + final result = await http.get(Uri.parse(url), headers: {}); + + if (result.statusCode == 404) { + _logger.warning( + 'Invalid status code for addon: ${addonManifest.name}', + ); + if (callback != null) { + callback( + null, + addon.name, + ArgumentError( + "Invalid status code for the addon ${addonManifest.name} with id ${addonManifest.id}", + ), + ); + } + } + + final body = StreamResponse.fromJson( + jsonDecode( + utf8.decode(result.bodyBytes), + ), + ); + + if (body.streams.isEmpty) { + _logger.finer('No stream data found for URL: $url'); + continue; + } + + streams.addAll( + body.streams.toList(), + ); + + if (callback != null) { + callback(streams, addonManifest.name, null); + } + } + }).catchError((error, stacktrace) { + _logger.severe('Error fetching streams', error, stacktrace); + if (callback != null) callback(null, null, error); + }); + + promises.add(future); + } + + await Future.wait(promises); + _logger.finer('Streams fetched successfully: ${streams.length} streams'); + return; + } + + bool doesAddonSupportStream( + ResourceObject resource, + StremioManifest addonManifest, + Meta meta, + ) { + if (resource.name != "stream") { + _logger.finer('Resource is not a stream: ${resource.name}'); + return false; + } + + final idPrefixes = + resource.idPrefixes ?? addonManifest.idPrefixes ?? resource.idPrefix; + + final types = resource.types ?? addonManifest.types; + + if (types == null || !types.contains(meta.type)) { + _logger.finer('Addon does not support type: ${meta.type}'); + return false; + } + + if ((idPrefixes ?? []).isEmpty == true) { + _logger.finer('No ID prefixes found, assuming support'); + return true; + } + + final hasIdPrefix = (idPrefixes ?? []).where( + (item) => + meta.id.startsWith(item) || meta.imdbId?.startsWith(item) == true, + ); + + if (hasIdPrefix.isEmpty) { + _logger.finer('No matching ID prefix found'); + return false; + } + + _logger.finer('Addon supports stream'); + return true; + } + + Query validateManifest( + String url, { + bool noCache = false, + }) { + url = url.replaceFirst( + "stremio://", + "https://", + ); + + return Query( + config: noCache + ? QueryConfig( + shouldRefetch: (context, a) => true, + ) + : _manifestQueryConfig, + key: 'manifest-validation-$url-v2', + queryFn: () async { + try { + final response = await http.get( + Uri.parse( + url, + ), + ); + if (response.statusCode != 200) { + throw Exception('Failed to load manifest'); + } + + final manifest = StremioManifest.fromJson( + jsonDecode(response.body), + url, + ); + + final hasRequiredResources = manifest.resources?.any((r) { + final name = r is String ? r : r.name; + return ['catalog', 'meta', 'stream', 'subtitles'] + .contains(name); + }) ?? + false; + + if (!hasRequiredResources) { + throw Exception( + 'Manifest must include catalog, meta, or stream resources', + ); + } + + return manifest; + } catch (e) { + throw Exception('Invalid manifest: $e'); + } + }, + ); + } + + Future saveAddon(StremioManifest manifest) async { + try { + final data = { + 'url': manifest.manifestUrl, + 'title': manifest.name, + 'icon': manifest.logo ?? manifest.icon, + 'enabled': true, + 'user': AppPocketBaseService.instance.pb.authStore.record!.id, + }; + + await AppPocketBaseService.instance.pb + .collection('stremio_addons') + .create(body: data); + + await getInstalledAddons().refetch(); + } catch (e, stack) { + print(e); + print(stack); + throw Exception('Failed to save addon: $e'); + } + } + + Query> getInstalledAddons({bool enabledOnly = true}) { + return Query>( + config: _manifestQueryConfig, + key: 'installed-addons-${enabledOnly ? 'enabled' : 'all'}', + queryFn: () async { + try { + final records = await AppPocketBaseService.instance.pb + .collection('stremio_addons') + .getFullList( + filter: enabledOnly ? 'enabled = true' : null, + ); + + final manifestFutures = records.map((record) async { + try { + final manifestQuery = validateManifest(record.data['url']); + return await manifestQuery.queryFn(); + } catch (e) { + _logger.warning( + 'Failed to load manifest: ${record.data['url']}, Error: $e'); + return null; + } + }).toList(); + + final results = await Future.wait(manifestFutures); + + final addons = results.whereType().toList(); + + return addons; + } catch (e) { + throw Exception('Failed to load installed addons: $e'); + } + }, + ); + } + + Future toggleAddonState(String url, bool enabled) async { + try { + final records = await AppPocketBaseService.instance.pb + .collection('stremio_addons') + .getFirstListItem( + 'url = "$url"', + ); + + records.set("enabled", enabled); + + await AppPocketBaseService.instance.pb + .collection('stremio_addons') + .update( + records.id, + body: records.toJson(), + ); + } catch (e, stack) { + _logger.warning("failed to toggle the state", e, stack); + throw Exception('Failed to update addon state: $e'); + } + } + + Future> getCatalog( + StremioManifest manifest, + String type, + String id, + int? page, + List items, + ) async { + String url = "${_getAddonBaseURL(manifest.manifestUrl!)}/catalog/$type/$id"; + + const perPage = 50; + + final catalog = manifest.catalogs?.firstWhereOrNull((item) { + return item.type == type && item.id == id; + }); + + if (catalog == null) { + _logger.info("Catalog not found $type $id"); + return []; + } + + if (page != null && catalog.extraSupported?.contains("skip") == true) { + items.add( + ConnectionFilterItem( + title: "skip", + value: page * perPage, + ), + ); + } + + if (page != null && catalog.extraSupported?.contains("region") == true) { + final region = AppPocketBaseService.instance.pb.authStore.record! + .getStringValue("region"); + + if (region.isNotEmpty) { + items.add( + ConnectionFilterItem( + title: "region", + value: region, + ), + ); + } + } + + if (page != null && catalog.extraSupported?.contains("language") == true) { + final language = AppPocketBaseService.instance.pb.authStore.record! + .getStringValue("language"); + + if (language.isNotEmpty) { + items.add( + ConnectionFilterItem( + title: "language", + value: language, + ), + ); + } + } + + final required = (catalog.extraRequired ?? []) + .where((item) => item != "featured") + .every((item) { + final result = items.firstWhereOrNull((allItem) { + return allItem.title == item; + }); + + return result != null; + }); + + if (required == false) { + _logger.info( + "required param is not available in the params for catalog ${catalog.type} ${catalog.id} ${catalog.extraRequired?.join(", ")}", + ); + return []; + } + + if (manifest.manifestVersion == "v2") { + if (items.isNotEmpty) { + String filterPath = items + .map((filter) { + final value = catalog.extraSupported?.contains(filter.title); + + if (value == null) { + return null; + } + + return "${filter.title}=${Uri.encodeComponent(filter.value.toString())}"; + }) + .whereType() + .join('&'); + + if (filterPath.isNotEmpty) { + url += "?$filterPath"; + } + } + + final httpBody = await http.get(Uri.parse(url)); + _logger.info("getting catalog from $url"); + + final metaInfo = StrmioMeta.fromJson( + jsonDecode(httpBody.body), + ); + + final db = AppDatabase(); + + return Future.wait( + (metaInfo.metas ?? []).map((item) async { + if (item.imdbId != null) { + final imdbRating = await db.getRatingByTConst(item.imdbId!); + + if (imdbRating == null) { + return item; + } + + return item.copyWith(imdbRating: imdbRating.toString()); + } + + return item; + }).toList(), + ); + } + + if (items.isNotEmpty) { + String filterPath = items + .map((filter) { + final value = catalog.extraSupported?.contains(filter.title); + + if (value == null) { + return null; + } + + return "${filter.title}=${Uri.encodeComponent(filter.value.toString())}"; + }) + .whereType() + .join('/'); + + if (filterPath.isNotEmpty) { + url += "/$filterPath"; + } + } + + url += ".json"; + + final httpBody = await http.get(Uri.parse(url)); + _logger.info("getting catalog from $url"); + final metaInfo = StrmioMeta.fromJson( + jsonDecode(httpBody.body), + ); + + return metaInfo.metas ?? []; + } + + _getAddonBaseURL(String input) { + return input.endsWith("/manifest.json") + ? input.replaceAll("/manifest.json", "") + : input; + } + + Future removeAddon(String url) async { + try { + final records = await AppPocketBaseService.instance.pb + .collection('stremio_addons') + .getFullList( + filter: 'url = "$url"', + ); + + if (records.isNotEmpty) { + await AppPocketBaseService.instance.pb + .collection('stremio_addons') + .delete(records.first.id); + } + await getInstalledAddons().refetch(); + } catch (e) { + throw Exception('Failed to remove addon: $e'); + } + } + + Future getMeta(String type, String id) async { + final addons = await getInstalledAddons(enabledOnly: true).queryFn(); + + for (final addon in addons) { + _logger.finer('Checking addon: $addon'); + + final manifest = addon; + + if (manifest.resources == null) { + _logger.finer('No resources found in manifest for addon: $addon'); + continue; + } + + List idPrefixes = []; + bool isMeta = false; + + for (final item in manifest.resources!) { + if (item.name == "meta") { + idPrefixes.addAll((item.idPrefix ?? []) + (item.idPrefixes ?? [])); + isMeta = true; + break; + } + } + + if (!isMeta) { + _logger.finer('No meta resource found in manifest for addon: $addon'); + continue; + } + + final ids = ((manifest.idPrefixes ?? []) + idPrefixes) + .firstWhere((item) => id.startsWith(item), orElse: () => ""); + + if (ids.isEmpty) { + _logger.finer('No matching ID prefix found for addon: $addon'); + continue; + } + + final result = await http.get( + Uri.parse( + "${_getAddonBaseURL(addon.manifestUrl!)}/meta/$type/$id.json", + ), + ); + + final item = jsonDecode(result.body); + + if (item['meta'] == null) { + _logger.finer('No meta data found for item: $id in addon: $addon'); + return null; + } + + final meta = StreamMetaResponse.fromJson(item).meta; + + final db = AppDatabase(); + + if (meta.imdbId != null) { + final rating = await db.getRatingByTConst(meta.imdbId!); + + return meta.copyWith( + imdbRating: rating != null ? rating.toString() : meta.imdbRating, + ); + } + + return meta; + } + + return Meta(type: "type", id: "id"); + } + + Future getPerson(String id) async { + final getInstalledAddon = await getInstalledAddons( + enabledOnly: true, + ).queryFn(); + + for (final value in getInstalledAddon) { + final resource = value.resources?.firstWhereOrNull((item) { + return item.name == "person"; + }); + + if (resource == null) { + continue; + } + + String url = + "${_getAddonBaseURL(value.manifestUrl!)}/person/tmdb:$id.json"; + + final result = await http.get(Uri.parse(url)); + + if (result.statusCode != 200) { + _logger.warning("failed with status ${result.statusCode}"); + continue; + } + + final person = utf8.decode(result.bodyBytes); + + final personData = jsonDecode(person); + + return CastMember.fromJson(personData['person']); + } + + return null; + } +} + +enum ConnectionFilterType { + text, + options, +} + +class ConnectionFilterItem { + final String title; + final dynamic value; + + ConnectionFilterItem({ + required this.title, + required this.value, + }); +} diff --git a/lib/features/streamio_addons/widget/add_addon_sheet.dart b/lib/features/streamio_addons/widget/add_addon_sheet.dart new file mode 100644 index 0000000..ed69d9d --- /dev/null +++ b/lib/features/streamio_addons/widget/add_addon_sheet.dart @@ -0,0 +1,180 @@ +import 'package:cached_query_flutter/cached_query_flutter.dart'; +import 'package:flutter/material.dart'; +import 'package:madari_client/features/streamio_addons/extension/query_extension.dart'; + +import '../models/stremio_base_types.dart'; +import '../service/stremio_addon_service.dart'; + +class AddAddonSheet extends StatefulWidget { + final VoidCallback onRefetch; + + const AddAddonSheet({ + super.key, + required this.onRefetch, + }); + + @override + State createState() => _AddAddonSheetState(); +} + +class _AddAddonSheetState extends State { + final _urlController = TextEditingController(); + Query? _validateQuery; + bool _isInstalling = false; + + final _exampleAddons = { + "Cinemeta": "https://v3-cinemeta.strem.io/manifest.json", + "Watchhub": "https://watchhub.strem.io/manifest.json", + "Subtitles": "https://opensubtitles-v3.strem.io/manifest.json", + }; + + @override + void dispose() { + _urlController.dispose(); + super.dispose(); + } + + void _validateManifest([String? url]) async { + final manifestUrl = (url ?? _urlController.text).replaceFirst( + "stremio://", + "https://", + ); + + if (manifestUrl.isEmpty) return; + + setState(() { + _isInstalling = true; + }); + + final query = await StremioAddonService.instance + .validateManifest( + manifestUrl.trim(), + ) + .queryFn(); + + await _installAddon(query); + + setState(() { + _isInstalling = false; + }); + } + + Future _handleExampleAddonTap(String name, String url) async { + final query = StremioAddonService.instance.validateManifest(url); + + final manifest = await query.queryFn(); + if (!mounted) return; + + _installAddon(manifest); + } + + Future _installAddon(StremioManifest manifest) async { + setState(() => _isInstalling = true); + + try { + await StremioAddonService.instance.saveAddon(manifest); + widget.onRefetch(); + + if (mounted) { + Navigator.of(context).pop(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to install addon: $e')), + ); + } + } finally { + if (mounted) { + setState(() => _isInstalling = false); + } + } + } + + @override + Widget build(BuildContext context) { + return DraggableScrollableSheet( + expand: false, + initialChildSize: 0.8, + minChildSize: 0.5, + maxChildSize: 0.95, + builder: (context, scrollController) => Container( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Row( + children: [ + const Text( + 'Add Stremio Addon', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ), + const Divider(), + Expanded( + child: ListView( + controller: scrollController, + padding: const EdgeInsets.symmetric(horizontal: 16), + children: [ + TextField( + controller: _urlController, + decoration: const InputDecoration( + labelText: 'Manifest URL', + hintText: 'Enter manifest URL', + ), + onSubmitted: (_) => _validateManifest(), + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: () => _validateManifest(), + icon: const Icon(Icons.check), + label: const Text('Validate'), + ), + const SizedBox(height: 24), + const Text( + 'Popular Addons', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: _exampleAddons.entries + .map((entry) => ActionChip( + label: Text(entry.key), + avatar: const Icon(Icons.add, size: 18), + onPressed: () => _handleExampleAddonTap( + entry.key, + entry.value, + ), + )) + .toList(), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/streamio_addons/widget/manifest_preview.dart b/lib/features/streamio_addons/widget/manifest_preview.dart new file mode 100644 index 0000000..35d78ad --- /dev/null +++ b/lib/features/streamio_addons/widget/manifest_preview.dart @@ -0,0 +1,137 @@ +import 'package:flutter/material.dart'; + +import '../models/stremio_base_types.dart'; + +class ManifestPreview extends StatelessWidget { + final StremioManifest manifest; + final VoidCallback onInstall; + final bool isLoading; + final String? error; + + const ManifestPreview({ + required this.manifest, + required this.onInstall, + required this.isLoading, + this.error, + super.key, + }); + + @override + Widget build(BuildContext context) { + final resources = manifest.resources + ?.map((r) => r.name) + .where((r) => ['catalog', 'meta', 'stream'].contains(r)) + .toList() ?? + []; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (manifest.logo != null) + Center( + child: Image.network( + manifest.logo!, + height: 120, + errorBuilder: (context, error, stackTrace) => + const Icon(Icons.extension, size: 80), + ), + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + manifest.name, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + if (manifest.description != null) ...[ + const SizedBox(height: 8), + Text(manifest.description!), + ], + const SizedBox(height: 16), + const Text( + 'Supported Features', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: resources + .map((r) => Chip( + label: Text(r), + backgroundColor: _getResourceColor(r), + )) + .toList(), + ), + if (manifest.types?.isNotEmpty == true) ...[ + const SizedBox(height: 16), + const Text( + 'Content Types', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: manifest.types! + .map((t) => Chip( + label: Text(t), + )) + .toList(), + ), + ], + ], + ), + ), + ), + const SizedBox(height: 16), + if (error != null) + Text( + error!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: isLoading ? null : onInstall, + icon: isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.add), + label: const Text('Install Addon'), + ), + ), + ], + ); + } + + Color _getResourceColor(String resource) { + switch (resource) { + case 'catalog': + return Colors.blue.withOpacity(0.2); + case 'meta': + return Colors.green.withOpacity(0.2); + case 'stream': + return Colors.orange.withOpacity(0.2); + default: + return Colors.grey.withOpacity(0.2); + } + } +} diff --git a/lib/features/streamio_addons/widget/stremio_addons_list.dart b/lib/features/streamio_addons/widget/stremio_addons_list.dart new file mode 100644 index 0000000..70256e8 --- /dev/null +++ b/lib/features/streamio_addons/widget/stremio_addons_list.dart @@ -0,0 +1,308 @@ +import 'package:cached_query_flutter/cached_query_flutter.dart'; +import 'package:flutter/material.dart'; +import 'package:shimmer/shimmer.dart'; + +import '../models/stremio_base_types.dart'; +import '../pages/stremio_addons_page.dart'; +import 'add_addon_sheet.dart'; + +class StremioAddonsList extends StatelessWidget { + final Query> query; + final bool showHidden; + + const StremioAddonsList({ + super.key, + required this.query, + required this.showHidden, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return QueryBuilder( + query: query, + builder: (context, state) { + if (state.status == QueryStatus.loading) { + return _buildShimmerList(colorScheme); + } + + if (state.error != null) { + return Center( + child: Card( + margin: const EdgeInsets.all(16), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.error_outline, + size: 48, + color: colorScheme.error, + ), + const SizedBox(height: 16), + Text( + 'Error loading addons', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + state.error.toString(), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: () => query.refetch(), + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + ), + ], + ), + ), + ), + ); + } + + final addons = state.data ?? []; + if (addons.isEmpty) { + return RefreshIndicator( + onRefresh: () => query.refetch(), + child: CustomScrollView( + slivers: [ + SliverFillRemaining( + child: Center( + child: Card( + margin: const EdgeInsets.all(16), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.extension_outlined, + size: 48, + color: colorScheme.primary, + ), + const SizedBox(height: 16), + Text( + 'No Addons Installed', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + 'Add your first addon to get started', + textAlign: TextAlign.center, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: () => _showAddAddonDialog(context), + icon: const Icon(Icons.add), + label: const Text('Add Addon'), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ); + } + + return RefreshIndicator( + onRefresh: () => query.refetch(), + child: ListView.separated( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 0), + itemCount: addons.length, + separatorBuilder: (context, index) => const SizedBox(height: 0), + itemBuilder: (context, index) { + final addon = addons[index]; + return _AddonListItem( + addon: addon, + onTap: () => _showManageAddonDialog(context, addon), + ); + }, + ), + ); + }, + ); + } + + Widget _buildShimmerList(ColorScheme colorScheme) { + return Shimmer.fromColors( + baseColor: colorScheme.surfaceContainerHighest.withValues(alpha: 0.4), + highlightColor: + colorScheme.surfaceContainerHighest.withValues(alpha: 0.2), + child: ListView.separated( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 0), + itemCount: 5, + separatorBuilder: (context, index) => const SizedBox(height: 8), + itemBuilder: (context, index) => _ShimmerAddonItem(), + ), + ); + } + + void _showAddAddonDialog(BuildContext context) { + showModalBottomSheet( + context: context, + builder: (context) => AddAddonSheet( + onRefetch: () { + query.refetch(); + }, + ), + useSafeArea: true, + isScrollControlled: true, + ); + } + + void _showManageAddonDialog(BuildContext context, StremioManifest addon) { + showDialog( + context: context, + builder: (context) => ManageAddonDialog( + addon: addon, + showHidden: showHidden, + ), + ); + } +} + +class _AddonListItem extends StatelessWidget { + final StremioManifest addon; + final VoidCallback onTap; + + const _AddonListItem({ + required this.addon, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Card( + clipBehavior: Clip.hardEdge, + color: colorScheme.brightness == Brightness.dark + ? Colors.black + : Colors.white, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: addon.logo != null + ? Padding( + padding: const EdgeInsets.all(8.0), + child: Image.network( + addon.logo!, + color: colorScheme.primary, + ), + ) + : Icon( + Icons.extension, + color: colorScheme.onPrimaryContainer, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + addon.name ?? 'Unknown Addon', + style: Theme.of(context).textTheme.titleMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (addon.description != null) ...[ + const SizedBox(height: 4), + Text( + addon.description!, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + const SizedBox(width: 8), + Icon( + Icons.chevron_right, + color: colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ), + ); + } +} + +class _ShimmerAddonItem extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 20, + width: 150, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(height: 8), + Container( + height: 16, + width: double.infinity, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(4), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/theme/provider/theme_provider.dart b/lib/features/theme/provider/theme_provider.dart new file mode 100644 index 0000000..14f09d7 --- /dev/null +++ b/lib/features/theme/provider/theme_provider.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; + +import '../service/theme_preferences.service.dart'; +import '../utils/color_utils.dart'; + +class ThemeProvider with ChangeNotifier { + static final ThemeProvider _instance = ThemeProvider._internal(); + + factory ThemeProvider() { + return _instance; + } + + ThemeProvider._internal() { + loadPreferences(); + } + + final ThemePreferences _themePreferences = ThemePreferences(); + final ColorUtils _colorUtils = ColorUtils(); + + bool _isDarkMode = false; + Color _primaryColor = Colors.red; + + bool get isDarkMode => _isDarkMode; + Color get primaryColor => _primaryColor; + + loadPreferences() async { + _isDarkMode = await _themePreferences.getThemeMode(); + _primaryColor = await _themePreferences.getPrimaryColor(); + notifyListeners(); + } + + void toggleTheme() { + _isDarkMode = !_isDarkMode; + _themePreferences.setThemeMode(_isDarkMode); + notifyListeners(); + } + + void setPrimaryColor(Color color) { + _primaryColor = color; + _themePreferences.setPrimaryColor(color); + notifyListeners(); + } + + MaterialColor get primarySwatch => + _colorUtils.createMaterialColor(_primaryColor); + + ThemeData getTheme() { + final colorScheme = ColorScheme.fromSeed( + seedColor: _primaryColor, + brightness: _isDarkMode ? Brightness.dark : Brightness.light, + ); + + return ThemeData( + colorScheme: colorScheme, + primarySwatch: primarySwatch, + useMaterial3: true, + ); + } +} diff --git a/lib/features/theme/service/theme_preferences.service.dart b/lib/features/theme/service/theme_preferences.service.dart new file mode 100644 index 0000000..426d2b5 --- /dev/null +++ b/lib/features/theme/service/theme_preferences.service.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class ThemePreferences { + static final ThemePreferences _instance = ThemePreferences._internal(); + + factory ThemePreferences() { + return _instance; + } + + ThemePreferences._internal(); + + static const themeMode = 'theme_mode'; + static const String primaryColorR = 'primary_color_r'; + static const String primaryColorG = 'primary_color_g'; + static const String primaryColorB = 'primary_color_b'; + + setThemeMode(bool isDarkMode) async { + SharedPreferences prefs = await SharedPreferences.getInstance(); + prefs.setBool(themeMode, isDarkMode); + } + + setPrimaryColor(Color color) async { + SharedPreferences prefs = await SharedPreferences.getInstance(); + prefs.setInt(primaryColorR, color.r.toInt()); + prefs.setInt(primaryColorG, color.g.toInt()); + prefs.setInt(primaryColorB, color.b.toInt()); + } + + Future getThemeMode() async { + SharedPreferences prefs = await SharedPreferences.getInstance(); + return prefs.getBool(themeMode) ?? true; + } + + Future getPrimaryColor() async { + SharedPreferences prefs = await SharedPreferences.getInstance(); + int r = prefs.getInt(primaryColorR) ?? 255; + int g = prefs.getInt(primaryColorG) ?? 0; + int b = prefs.getInt(primaryColorB) ?? 0; + + return Color.fromARGB(255, r, g, b); + } +} diff --git a/lib/features/theme/theme/app_theme.dart b/lib/features/theme/theme/app_theme.dart new file mode 100644 index 0000000..d4c7572 --- /dev/null +++ b/lib/features/theme/theme/app_theme.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +import '../provider/theme_provider.dart'; +import '../utils/color_utils.dart'; + +class AppTheme { + static final AppTheme _instance = AppTheme._internal(); + + factory AppTheme() { + return _instance; + } + + AppTheme._internal(); + + final ThemeProvider _themeProvider = ThemeProvider(); + final ColorUtils _colorUtils = ColorUtils(); + + ThemeProvider get themeProvider => _themeProvider; + ColorUtils get colorUtils => _colorUtils; + + void toggleTheme() => _themeProvider.toggleTheme(); + + void setPrimaryColor(Color color) => _themeProvider.setPrimaryColor(color); + + void setPrimaryColorFromRGB(int r, int g, int b) { + Color color = _colorUtils.colorFromRGB(r, g, b); + _themeProvider.setPrimaryColor(color); + } + + ThemeData getCurrentTheme() => _themeProvider.getTheme(); +} diff --git a/lib/features/theme/utils/color_utils.dart b/lib/features/theme/utils/color_utils.dart new file mode 100644 index 0000000..b45eefc --- /dev/null +++ b/lib/features/theme/utils/color_utils.dart @@ -0,0 +1,53 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +class ColorUtils { + static final ColorUtils _instance = ColorUtils._internal(); + + factory ColorUtils() { + return _instance; + } + + ColorUtils._internal(); + + MaterialColor createMaterialColor(Color color) { + HSLColor hslColor = HSLColor.fromColor(color); + + Map shades = { + 50: _createShade(hslColor, 0.9), + 100: _createShade(hslColor, 0.8), + 200: _createShade(hslColor, 0.7), + 300: _createShade(hslColor, 0.6), + 400: _createShade(hslColor, 0.5), + 500: color, + 600: _createShade(hslColor, 0.4), + 700: _createShade(hslColor, 0.3), + 800: _createShade(hslColor, 0.2), + 900: _createShade(hslColor, 0.1), + }; + + return MaterialColor(_colorToInt(color), shades); + } + + Color _createShade(HSLColor hslColor, double factor) { + double lightness = + math.min(1.0, math.max(0.0, hslColor.lightness * factor)); + return hslColor.withLightness(lightness).toColor(); + } + + int _colorToInt(Color color) { + return (0xFF << 24) | + (color.r.toInt() << 16) | + (color.g.toInt() << 8) | + color.b.toInt(); + } + + Color colorFromRGB(int r, int g, int b) { + return Color.fromARGB(255, r, g, b); + } + + List colorToRGB(Color color) { + return [color.r.toInt(), color.g.toInt(), color.b.toInt()]; + } +} diff --git a/lib/features/trakt/containers/up_next.container.dart b/lib/features/trakt/containers/up_next.container.dart deleted file mode 100644 index 83fb0b6..0000000 --- a/lib/features/trakt/containers/up_next.container.dart +++ /dev/null @@ -1,361 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:logging/logging.dart'; -import 'package:madari_client/features/connections/service/base_connection_service.dart'; -import 'package:madari_client/features/trakt/service/trakt.service.dart'; -import 'package:madari_client/utils/common.dart'; - -import '../../connections/types/stremio/stremio_base.types.dart'; -import '../../connections/widget/base/render_library_list.dart'; -import '../../settings/screen/trakt_integration_screen.dart'; - -class TraktContainer extends StatefulWidget { - final String loadId; - final int itemsPerPage; - - const TraktContainer({ - super.key, - required this.loadId, - this.itemsPerPage = 5, - }); - - @override - State createState() => TraktContainerState(); -} - -class TraktContainerState extends State { - final Logger _logger = Logger('TraktContainerState'); - - List? _cachedItems; - bool _isLoading = false; - String? _error; - - int _currentPage = 1; - - final _scrollController = ScrollController(); - - StreamSubscription>? _steam; - - bool get _isBottom { - if (!_scrollController.hasClients) return false; - final maxScroll = _scrollController.position.maxScrollExtent; - final currentScroll = _scrollController.offset; - return currentScroll >= (maxScroll * 0.9); - } - - @override - void initState() { - super.initState(); - _logger.info('Initializing TraktContainerState'); - _loadData(); - - _steam = TraktService.instance?.refetchKey.stream.listen((item) { - if (item.contains(widget.loadId)) { - _logger.info("refreshing widget ${widget.loadId}"); - refresh(); - } - }); - - _scrollController.addListener(() { - if (_isBottom) { - _loadData(isLoadMore: true); - } - }); - } - - @override - void dispose() { - _logger.info('Disposing TraktContainerState'); - _scrollController.dispose(); - _steam?.cancel(); - super.dispose(); - } - - Future _loadData({ - bool isLoadMore = false, - }) async { - _logger.info('Started loading data for the _loadData'); - if (_isLoading) { - _logger.warning('Load data called while already loading'); - return; - } - _isLoading = true; - - setState(() { - _error = null; - }); - - try { - final page = isLoadMore ? _currentPage + 1 : _currentPage; - - List? newItems; - - _logger.info('Loading data for loadId: ${widget.loadId}, page: $page'); - - switch (widget.loadId) { - case "up_next_series": - newItems = await TraktService.instance! - .getUpNextSeries( - page: page, - itemsPerPage: widget.itemsPerPage, - ) - .first; - break; - case "continue_watching": - newItems = await TraktService.instance!.getContinueWatching( - page: page, - itemsPerPage: widget.itemsPerPage, - ); - break; - case "upcoming_schedule": - newItems = await TraktService.instance!.getUpcomingSchedule( - page: page, - itemsPerPage: widget.itemsPerPage, - ); - break; - case "watchlist": - newItems = await TraktService.instance!.getWatchlist( - page: page, - itemsPerPage: widget.itemsPerPage, - ); - break; - case "show_recommendations": - newItems = await TraktService.instance!.getShowRecommendations( - page: page, - itemsPerPage: widget.itemsPerPage, - ); - break; - case "movie_recommendations": - newItems = await TraktService.instance!.getMovieRecommendations( - page: page, - itemsPerPage: widget.itemsPerPage, - ); - break; - default: - _logger.severe('Invalid loadId: ${widget.loadId}'); - throw Exception("Invalid loadId: ${widget.loadId}"); - } - - if (mounted) { - setState(() { - _currentPage = page; - _cachedItems = [...?_cachedItems, ...?newItems]; - _isLoading = false; - }); - - _logger.info('Data loaded successfully for loadId: ${widget.loadId}'); - } - } catch (e) { - _logger.severe('Error loading data: $e'); - if (mounted) { - setState(() { - _error = e.toString(); - _isLoading = false; - }); - } - } - } - - late final Map> actions = { - "continue_watching": [ - ContextMenuItem( - id: "remove", - icon: CupertinoIcons.clear, - title: 'Remove', - isDestructiveAction: true, - onCallback: (action, key) async { - if (key is! Meta) { - return; - } - - if (key.traktProgressId == null) { - return; - } - - await TraktService.instance!.removeFromContinueWatching( - key.traktProgressId!.toString(), - ); - - if (context.mounted && mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text("Removed successfully"), - ), - ); - } - }, - ), - ], - "watchlist": [ - ContextMenuItem( - id: "remove", - icon: CupertinoIcons.clear, - title: 'Remove', - isDestructiveAction: true, - onCallback: (action, key) { - TraktService.instance!.removeFromWatchlist(key as Meta); - }, - ), - ], - }; - - Future refresh() async { - try { - _logger.info('Refreshing data for ${widget.loadId}'); - _cachedItems = []; - _currentPage = 1; - await _loadData(); - } catch (e) {} - } - - String get title { - return traktCategories - .firstWhere((item) => item.key == widget.loadId) - .title; - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Container( - margin: const EdgeInsets.only(bottom: 6), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - title, - style: theme.textTheme.bodyLarge, - ), - const Spacer(), - SizedBox( - height: 30, - child: TextButton( - onPressed: () { - _logger.info('Navigating to Trakt details page'); - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) { - return Scaffold( - appBar: AppBar( - title: Text("Trakt - $title"), - ), - body: Padding( - padding: const EdgeInsets.all(8.0), - child: RenderListItems( - loadMore: () { - _loadData( - isLoadMore: true, - ); - }, - items: _cachedItems ?? [], - error: _error, - contextMenuItems: - actions.containsKey(widget.loadId) - ? actions[widget.loadId]! - : [], - onContextMenu: (action, items) { - actions[widget.loadId]! - .firstWhereOrNull((item) { - return item.id == action; - })?.onCallback!(action, items); - }, - isLoadingMore: _isLoading, - hasError: _error != null, - heroPrefix: "trakt_up_next${widget.loadId}", - service: TraktService.stremioService!, - isGrid: true, - isWide: widget.loadId == "up_next_series", - ), - ), - ); - }, - ), - ); - }, - child: Text( - "Show more", - style: theme.textTheme.labelMedium?.copyWith( - color: Colors.white70, - ), - ), - ), - ), - ], - ), - const SizedBox( - height: 8, - ), - Stack( - children: [ - if ((_cachedItems ?? []).isEmpty && !_isLoading && _error != null) - const Positioned.fill( - child: Center( - child: Text("Nothing to see here"), - ), - ), - if (_isLoading && (_cachedItems ?? []).isEmpty) - const SpinnerCards(), - if (_error != null) Text(_error!), - if (_error != null) - Positioned.fill( - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - "Error: $_error", - style: theme.textTheme.bodyMedium?.copyWith( - color: Colors.red, - ), - ), - const SizedBox(height: 10), - ElevatedButton( - onPressed: () { - setState(() { - _error = null; - }); - _loadData(); - }, - child: const Text("Retry"), - ), - ], - ), - ), - ), - if (_error == null) - SizedBox( - height: getListHeight(context), - child: RenderListItems( - isWide: widget.loadId == "up_next_series" || - widget.loadId == "upcoming_schedule", - items: _cachedItems ?? [], - error: _error, - contextMenuItems: actions.containsKey(widget.loadId) - ? actions[widget.loadId]! - : [], - onContextMenu: (action, items) async { - actions[widget.loadId]!.firstWhereOrNull((item) { - return item.id == action; - })?.onCallback!(action, items); - - Navigator.of(context, rootNavigator: true).pop(); - }, - itemScrollController: _scrollController, - hasError: _error != null, - heroPrefix: "trakt_up_next${widget.loadId}", - service: TraktService.stremioService!, - ), - ), - ], - ) - ], - ), - ); - } -} diff --git a/lib/features/trakt/service/trakt.service.dart b/lib/features/trakt/service/trakt.service.dart deleted file mode 100644 index 25b49b5..0000000 --- a/lib/features/trakt/service/trakt.service.dart +++ /dev/null @@ -1,1081 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:cached_query/cached_query.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; -import 'package:http/http.dart' as http; -import 'package:intl/intl.dart'; -import 'package:logging/logging.dart'; -import 'package:madari_client/utils/common.dart'; -import 'package:pocketbase/pocketbase.dart'; -import 'package:rxdart/rxdart.dart'; - -import '../../../engine/connection_type.dart'; -import '../../../engine/engine.dart'; -import '../../connections/service/base_connection_service.dart'; -import '../../connections/types/stremio/stremio_base.types.dart'; -import '../../settings/types/connection.dart'; - -class TraktService { - static final Logger _logger = Logger('TraktService'); - - static const String _baseUrl = 'https://api.trakt.tv'; - static const String _apiVersion = '2'; - - final refetchKey = BehaviorSubject>(); - - static TraktService? _instance; - static TraktService? get instance => _instance; - static BaseConnectionService? stremioService; - - Map _cache = {}; - - saveCacheToDisk() { - _logger.fine('Saving cache to disk'); - CachedQuery.instance.storage?.put( - StoredQuery( - key: "trakt_integration_cache", - data: _cache, - createdAt: DateTime.now(), - ), - ); - } - - Future removeFromContinueWatching(String id) async { - if (!isEnabled()) { - _logger.info('Trakt integration is not enabled'); - return; - } - - try { - _logger.info( - 'Removing item from history (continue watching): $id', - ); - - final response = await http.delete( - Uri.parse('$_baseUrl/sync/playback/$id'), - headers: headers, - ); - - if (response.statusCode != 204) { - _logger.severe( - 'Failed to remove item from history: ${response.statusCode} $id', - ); - throw Exception('Failed to remove item from history'); - } - - _cache.remove('$_baseUrl/sync/watched/shows'); - _cache.remove('$_baseUrl/sync/playback'); - - refetchKey.add(["continue_watching", "up_next_series"]); - - _logger.info( - 'Successfully removed item from history (continue watching)', - ); - } catch (e, stack) { - _logger.severe('Error removing item from history: $e', stack); - rethrow; - } - } - - Future removeFromWatchlist(Meta meta) async { - if (!isEnabled()) { - _logger.info('Trakt integration is not enabled'); - return; - } - - try { - _logger.info('Removing item from watchlist: ${meta.id}'); - - final response = await http.post( - Uri.parse('$_baseUrl/sync/watchlist/remove'), - headers: headers, - body: json.encode({ - if (meta.type == "movie") - 'movies': [ - { - 'ids': { - 'imdb': meta.id, - }, - }, - ], - if (meta.type == "shows") - 'shows': [ - { - 'ids': { - 'imdb': meta.id, - }, - } - ], - }), - ); - - if (response.statusCode != 200) { - _logger.severe( - 'Failed to remove item from watchlist: ${response.statusCode}'); - throw Exception('Failed to remove item from watchlist'); - } - - _cache.remove('$_baseUrl/sync/watchlist'); - - refetchKey.add(["watchlist"]); - - _logger.info('Successfully removed item from watchlist'); - } catch (e, stack) { - _logger.severe('Error removing item from watchlist: $e', stack); - rethrow; - } - } - - clearCache() { - _logger.info('Clearing cache'); - _cache.clear(); - } - - static ensureInitialized() async { - if (_instance != null) { - _logger.fine('Instance already initialized'); - return _instance; - } - - _logger.info('Initializing TraktService'); - - final result = - await CachedQuery.instance.storage?.get("trakt_integration_cache"); - - AppEngine.engine.pb.authStore.onChange.listen((item) { - if (!AppEngine.engine.pb.authStore.isValid) { - _logger.info('Auth store is invalid, clearing cache'); - _instance?._cache.clear(); - } - }); - - final traktService = TraktService(); - await traktService.initStremioService(); - _instance = traktService; - - _instance?._cache = result?.data ?? {}; - - _instance!._startCacheRevalidation(); - } - - Future initStremioService() async { - if (stremioService != null) { - _logger.fine('StremioService already initialized'); - return stremioService!; - } - - _logger.info('Initializing StremioService'); - - final model_ = - await AppEngine.engine.pb.collection("connection").getFirstListItem( - "type.type = 'stremio_addons'", - expand: "type", - ); - - final connection = ConnectionResponse( - connection: Connection.fromRecord(model_), - connectionTypeRecord: ConnectionTypeRecord.fromRecord( - model_.get("expand.type"), - ), - ); - - stremioService = BaseConnectionService.connectionById(connection); - - return stremioService!; - } - - static String get _traktClient { - final client = "" ?? DotEnv().get("trakt_client_id"); - - if (client == "") { - _logger.warning('Using default Trakt client ID'); - return "b47864365ac88ecc253c3b0bdf1c82a619c1833e8806f702895a7e8cb06b536a"; - } - - return client; - } - - get _token { - return AppEngine.engine.pb.authStore.record!.getStringValue("trakt_token"); - } - - final Map> _activeScrobbleRequests = {}; - - List debugLogs = []; - - Map get headers => { - 'Content-Type': 'application/json', - 'trakt-api-version': _apiVersion, - 'trakt-api-key': _traktClient, - 'Authorization': 'Bearer $_token', - }; - - void _startCacheRevalidation() { - _logger.info('Starting cache revalidation timer'); - } - - Future _makeRequest(String url, {bool bypassCache = false}) async { - if (!bypassCache && _cache.containsKey(url)) { - _logger.fine('Returning cached data for $url'); - return _cache[url]; - } - - _logger.info('Making GET request to $url'); - final response = await http.get(Uri.parse(url), headers: headers); - - if (response.statusCode != 200) { - _logger.severe('Failed to fetch data from $url: ${response.statusCode}'); - throw Exception('Failed to fetch data from $url ${response.statusCode}'); - } - - _logger.info('Successfully fetched data from $url'); - final data = json.decode(response.body); - _cache[url] = data; - - saveCacheToDisk(); - - return data; - } - - Map _buildObjectForMeta(Meta meta) { - if (meta.type == "movie") { - return { - 'movie': { - 'title': meta.name, - 'year': meta.year, - 'ids': { - 'imdb': meta.imdbId ?? meta.id, - if (meta.tvdbId != null) 'tvdb': meta.tvdbId, - }, - }, - }; - } else { - if (meta.currentVideo?.tvdbId != null) { - return { - "episode": { - "ids": { - "tvdb": meta.currentVideo?.tvdbId!, - }, - }, - }; - } - - if (meta.currentVideo?.season != null && - meta.currentVideo?.episode != null) { - return { - "episode": { - "season": meta.currentVideo!.season, - "episode": meta.currentVideo!.episode, - }, - "show": { - "ids": { - "imdb": meta.imdbId ?? meta.id, - } - }, - }; - } - - return { - "episode": { - "ids": { - "imdb": meta.currentVideo?.id ?? meta.id, - } - } - }; - } - } - - Stream> getUpNextSeries({ - int page = 1, - int itemsPerPage = 5, - }) async* { - await initStremioService(); - - if (!isEnabled()) { - _logger.info('Trakt integration is not enabled'); - yield []; - return; - } - - try { - _logger.info('Fetching up next series'); - final List watchedShows = await _makeRequest( - '$_baseUrl/sync/watched/shows', - ); - - if (watchedShows.isEmpty) { - return; - } - - final startIndex = - ((page - 1) * itemsPerPage).clamp(0, watchedShows.length - 1); - final endIndex = - (startIndex + itemsPerPage).clamp(0, watchedShows.length - 1); - - final items = watchedShows.toList(); - - if (startIndex >= items.length) { - yield []; - return; - } - - final paginatedItems = items.sublist( - startIndex, - endIndex > items.length ? items.length : endIndex, - ); - - final progressFutures = paginatedItems.map((show) async { - final showId = show['show']['ids']['trakt']; - final imdb = show['show']['ids']['imdb']; - - try { - final progress = await _makeRequest( - '$_baseUrl/shows/$showId/progress/watched', - ); - - final nextEpisode = progress['next_episode']; - - if (nextEpisode != null && imdb != null) { - final item = await stremioService!.getItemById( - Meta( - type: "series", - id: imdb, - ), - ); - - return patchMetaObjectForShow(item as Meta, nextEpisode); - } - } catch (e, stack) { - _logger.severe( - 'Error fetching progress for show $showId: $e', - e, - stack, - ); - return null; - } - - return null; - }).toList(); - - final results = await Future.wait(progressFutures); - final validResults = results.whereType().toList(); - - final paginatedResults = validResults; - - yield paginatedResults; - } catch (e, stack) { - _logger.severe('Error fetching up next episodes: $e', stack); - yield []; - } - } - - Meta patchMetaObjectForShow( - Meta meta, - dynamic obj, { - double? progress, - }) { - if (meta.videos?.isEmpty == true) { - meta.videos = []; - meta.videos?.add( - Video( - season: obj['season'], - number: obj['number'], - thumbnail: meta.poster, - id: _traktIdsToMetaId(obj['ids']), - ), - ); - return meta; - } - - final videoIndexByTvDB = meta.videos?.firstWhereOrNull((item) { - return item.tvdbId == obj['ids']['tvdb'] && item.tvdbId != null; - }); - - final videoBySeasonOrEpisode = meta.videos?.firstWhereOrNull((item) { - return item.season == obj['season'] && item.episode == obj['number']; - }); - - final video = videoIndexByTvDB ?? videoBySeasonOrEpisode; - - if (video == null) { - final id = _traktIdsToMetaId(obj['ids']); - - meta.videos = meta.videos ?? []; - - meta.videos?.add( - Video( - name: obj['title'], - season: obj['season'], - number: obj['number'], - thumbnail: meta.poster, - id: id, - episode: obj['number'], - ), - ); - - final videosIndex = meta.videos?.length ?? 1; - - return meta.copyWith( - selectedVideoIndex: videosIndex - 1, - ); - } - - final index = meta.videos?.indexOf(video); - - meta.videos![index!].name = obj['title']; - meta.videos![index].tvdbId = - meta.videos![index].tvdbId ?? obj['ids']['tvdb']; - meta.videos![index].ids = obj['ids']; - - return meta.copyWith( - selectedVideoIndex: index, - ); - } - - String _traktIdsToMetaId(dynamic ids) { - String id; - - if (ids['imdb'] != null) { - id = ids['imdb']; - } else if (ids['tmdb'] != null) { - id = "tmdb:${ids['tmdb']}"; - } else if (ids['trakt']) { - id = "trakt:${ids['trakt']}"; - } else { - id = "na"; - } - - return id; - } - - Future> getContinueWatching({ - int page = 1, - int itemsPerPage = 5, - }) async { - await initStremioService(); - - if (!isEnabled()) { - _logger.info('Trakt integration is not enabled'); - return []; - } - - try { - _logger.info('Fetching continue watching'); - final List continueWatching = - await _makeRequest('$_baseUrl/sync/playback'); - - if (continueWatching.isEmpty) { - return []; - } - - continueWatching.sort((v2, v1) => DateTime.parse(v1["paused_at"]) - .compareTo(DateTime.parse(v2["paused_at"]))); - - final startIndex = - ((page - 1) * itemsPerPage).clamp(0, continueWatching.length - 1); - final endIndex = - (startIndex + itemsPerPage).clamp(0, continueWatching.length - 1); - - if (startIndex >= continueWatching.length) { - return []; - } - - final metaList = (await Future.wait(continueWatching - .sublist( - startIndex, - endIndex, - ) - .map((movie) async { - try { - if (movie['type'] == 'episode') { - final meta = Meta( - type: "series", - id: _traktIdsToMetaId( - movie['show']['ids'], - ), - ); - - return patchMetaObjectForShow( - (await stremioService!.getItemById(meta) as Meta), - movie['episode'], - ).copyWith( - forceRegular: true, - progress: movie['progress'], - traktProgressId: movie['id'], - ); - } - - final movieId = _traktIdsToMetaId(movie['movie']['ids']); - - final meta = Meta( - type: "movie", - id: movieId, - ); - - return ((await stremioService!.getItemById(meta)) as Meta).copyWith( - progress: movie['progress'], - traktProgressId: movie['id'], - ); - } catch (e, stack) { - _logger.warning( - 'Error mapping movie: $e', - e, - stack, - ); - return null; - } - }))) - .whereType() - .toList(); - - return metaList; - } catch (e, stack) { - _logger.severe('Error fetching continue watching: $e', stack); - return []; - } - } - - Future> getUpcomingSchedule({ - int page = 1, - int itemsPerPage = 5, - }) async { - await initStremioService(); - - if (!isEnabled()) { - _logger.info('Trakt integration is not enabled'); - return []; - } - - try { - _logger.info('Fetching upcoming schedule'); - final List scheduleShows = await _makeRequest( - '$_baseUrl/calendars/my/shows/${DateFormat('yyyy-MM-dd').format(DateTime.now())}/7', - ); - - if (scheduleShows.isEmpty) { - return []; - } - - final startIndex = - ((page - 1) * itemsPerPage).clamp(0, scheduleShows.length - 1); - final endIndex = - (startIndex + itemsPerPage).clamp(0, scheduleShows.length - 1); - - if (startIndex >= scheduleShows.length) { - return []; - } - - final result = (await Future.wait(scheduleShows - .sublist( - startIndex, - endIndex > scheduleShows.length ? scheduleShows.length : endIndex, - ) - .map((show) async { - try { - final imdb = _traktIdsToMetaId( - show['show']['ids'], - ); - - final result = Meta( - type: "series", - id: imdb, - ); - - final item = await stremioService!.getItemById(result); - - return patchMetaObjectForShow( - (item ?? result) as Meta, - show['episode'], - ).copyWith( - progress: null, - ); - } catch (e) { - _logger.warning('Error mapping show: $e'); - return null; - } - }))) - .whereType() - .toList(); - - return result; - } catch (e, stack) { - _logger.severe('Error fetching upcoming schedule: $e', stack); - return []; - } - } - - Future> getWatchlist( - {int page = 1, int itemsPerPage = 5}) async { - await initStremioService(); - - if (!isEnabled()) { - _logger.info('Trakt integration is not enabled'); - return []; - } - - try { - _logger.info('Fetching watchlist'); - final List watchlistItems = - await _makeRequest('$_baseUrl/sync/watchlist'); - _logger.info('Got watchlist'); - - if (watchlistItems.isEmpty) { - return []; - } - - final startIndex = - ((page - 1) * itemsPerPage).clamp(0, watchlistItems.length - 1); - final endIndex = - (startIndex + itemsPerPage).clamp(0, watchlistItems.length - 1); - - if (startIndex >= watchlistItems.length) { - return []; - } - - final result = await stremioService!.getBulkItem( - watchlistItems - .sublist( - startIndex, - endIndex > watchlistItems.length - ? watchlistItems.length - : endIndex, - ) - .map((item) { - try { - final type = item['type']; - final imdb = _traktIdsToMetaId(item[type]['ids']); - - if (type == "show") { - return Meta( - type: "series", - id: imdb, - ); - } - - return Meta( - type: type, - id: imdb, - ); - } catch (e, stack) { - _logger.warning('Error mapping watchlist item: $e', e, stack); - return null; - } - }) - .whereType() - .toList(), - ); - - return result; - } catch (e, stack) { - _logger.severe('Error fetching watchlist: $e', stack); - return []; - } - } - - Future> getShowRecommendations({ - int page = 1, - int itemsPerPage = 5, - }) async { - await initStremioService(); - - if (!isEnabled()) { - _logger.info('Trakt integration is not enabled'); - return []; - } - - try { - _logger.info('Fetching show recommendations'); - final List recommendedShows = await _makeRequest( - '$_baseUrl/recommendations/shows', - ); - - if (recommendedShows.isEmpty) { - return []; - } - - final startIndex = - ((page - 1) * itemsPerPage).clamp(0, recommendedShows.length - 1); - final endIndex = - (startIndex + itemsPerPage).clamp(0, recommendedShows.length - 1); - - if (startIndex >= recommendedShows.length) { - return []; - } - - final result = (await stremioService!.getBulkItem( - recommendedShows - .sublist( - startIndex, - endIndex > recommendedShows.length - ? recommendedShows.length - : endIndex, - ) - .map((show) { - final imdb = _traktIdsToMetaId(show['ids']); - - return Meta( - type: "series", - id: imdb, - ); - }) - .whereType() - .toList(), - )); - - return result; - } catch (e, stack) { - _logger.severe('Error fetching show recommendations: $e', stack); - return []; - } - } - - Future> getMovieRecommendations({ - int page = 1, - int itemsPerPage = 5, - }) async { - await initStremioService(); - - if (!isEnabled()) { - _logger.info('Trakt integration is not enabled'); - return []; - } - - try { - _logger.info('Fetching movie recommendations'); - final List recommendedMovies = await _makeRequest( - '$_baseUrl/recommendations/movies', - ); - - if (recommendedMovies.isEmpty) { - return []; - } - - final startIndex = - ((page - 1) * itemsPerPage).clamp(0, recommendedMovies.length - 1); - final endIndex = - (startIndex + itemsPerPage).clamp(0, recommendedMovies.length - 1); - - if (startIndex >= recommendedMovies.length) { - return []; - } - - final result = await stremioService!.getBulkItem( - recommendedMovies - .sublist( - startIndex, - endIndex > recommendedMovies.length - ? recommendedMovies.length - : endIndex, - ) - .map((movie) { - try { - final imdb = _traktIdsToMetaId(movie['ids']); - return Meta( - type: "movie", - id: imdb, - ); - } catch (e) { - _logger.warning('Error mapping movie: $e'); - return null; - } - }) - .whereType() - .toList(), - ); - - return result; - } catch (e, stack) { - _logger.severe('Error fetching movie recommendations: $e', stack); - return []; - } - } - - List getHomePageContent() { - final List config = ((AppEngine.engine.pb.authStore.record - ?.get("config")?["selected_categories"] ?? - []) as List) - .whereType() - .toList(); - - if (!isEnabled()) { - _logger.info('Trakt integration is not enabled'); - return []; - } - - return config; - } - - static bool isEnabled() { - return AppEngine.engine.pb.authStore.record! - .getStringValue("trakt_token") != - ""; - } - - Future getTraktIdForMovie(String imdb) async { - _logger.info('Fetching Trakt ID for movie with IMDb ID: $imdb'); - final body = await _makeRequest("$_baseUrl/search/imdb/$imdb"); - - if (body.isEmpty) { - _logger.warning('No Trakt ID found for IMDb ID: $imdb'); - return null; - } - - final firstItem = body.first; - - if (firstItem["type"] == "show") { - return body[0]['show']['ids']['trakt']; - } - - if (firstItem["type"] == "movie") { - return body[0]['movie']['ids']['trakt']; - } - - return null; - } - - Future startScrobbling({ - required Meta meta, - required double progress, - }) async { - if (!isEnabled()) { - _logger.info('Trakt integration is not enabled'); - return; - } - - try { - _logger.info('Starting scrobbling for ${meta.type} with ID: ${meta.id}'); - - final response = await http.post( - Uri.parse('$_baseUrl/scrobble/start'), - headers: headers, - body: json.encode({ - 'progress': progress, - ..._buildObjectForMeta(meta), - }), - ); - - if (response.statusCode == 404) { - _logger.severe('Failed to start scrobbling: ${response.statusCode}'); - _logger.severe("${_buildObjectForMeta(meta)}"); - return; - } - - if (response.statusCode != 201) { - _logger.severe('Failed to start scrobbling: ${response.statusCode}'); - throw Exception('Failed to start scrobbling'); - } - - _logger.info('Scrobbling started successfully'); - _cache.remove('$_baseUrl/sync/watched/shows'); - _cache.remove('$_baseUrl/sync/playback'); - } catch (e, stack) { - _logger.severe('Error starting scrobbling: $e', stack); - rethrow; - } - } - - Future pauseScrobbling({ - required Meta meta, - required double progress, - }) async { - if (!isEnabled()) { - _logger.info('Trakt integration is not enabled'); - return; - } - - final cacheKey = '${meta.id}_pauseScrobbling'; - - _activeScrobbleRequests[cacheKey]?.completeError('Cancelled'); - _activeScrobbleRequests[cacheKey] = Completer(); - - try { - _logger.info('Pausing scrobbling for ${meta.type} with ID: ${meta.id}'); - await _retryPostRequest( - cacheKey, - '$_baseUrl/scrobble/pause', - { - 'progress': progress, - ..._buildObjectForMeta(meta), - }, - ); - } catch (e, stack) { - _logger.severe('Error pausing scrobbling: $e', stack); - rethrow; - } finally { - _activeScrobbleRequests.remove(cacheKey); - } - } - - Future _retryPostRequest( - String cacheKey, - String url, - Map body, { - int retryCount = 2, - }) async { - for (int i = 0; i < retryCount; i++) { - try { - _logger.info('Making POST request to $url'); - final response = await http.post( - Uri.parse(url), - headers: headers, - body: json.encode(body), - ); - - if (response.statusCode == 404) { - _logger.warning('could not find episode'); - } else if (response.statusCode == 201) { - _logger.info('POST request successful'); - return; - } else if (response.statusCode == 429) { - _logger.warning('Rate limit hit, retrying...'); - await Future.delayed( - const Duration(seconds: 10), - ); - continue; - } else { - _logger.severe('Failed to make POST request: ${response.statusCode}'); - throw Exception( - 'Failed to make POST request: ${response.statusCode}', - ); - } - } catch (e) { - if (i == retryCount - 1) { - _logger - .severe('Failed to make POST request after $retryCount attempts'); - if (_cache.containsKey(cacheKey)) { - _logger.info('Returning cached data'); - return _cache[cacheKey]; - } - rethrow; - } - } - } - } - - Future stopScrobbling({ - required Meta meta, - required double progress, - bool shouldClearCache = false, - int? traktId, - }) async { - if (!isEnabled()) { - _logger.info('Trakt integration is not enabled'); - return; - } - - final cacheKey = '${meta.id}_stopScrobbling'; - - _activeScrobbleRequests[cacheKey]?.completeError('Cancelled'); - _activeScrobbleRequests[cacheKey] = Completer(); - - try { - _logger.info('Stopping scrobbling for ${meta.type} with ID: ${meta.id}'); - _logger.info(_buildObjectForMeta(meta)); - await _retryPostRequest( - cacheKey, - '$_baseUrl/scrobble/stop', - { - 'progress': progress, - ..._buildObjectForMeta(meta), - }, - ); - - if (shouldClearCache) { - _cache.remove('$_baseUrl/sync/watched/shows'); - _cache.remove('$_baseUrl/sync/playback'); - - final keys = [ - if (traktId != null) "$_baseUrl/shows/$traktId/progress/watched", - "continue_watching", - if (meta.type == "series") "up_next_series", - ]; - refetchKey.add(keys); - - _logger.info( - "pushing refetch key ${keys.join(", ")} still in cache ${_cache.keys.join(", ")}", - ); - } - } catch (e, stack) { - _logger.severe('Error stopping scrobbling: $e', stack); - rethrow; - } finally { - _activeScrobbleRequests.remove(cacheKey); - } - } - - Future getProgress( - Meta meta, { - bool bypassCache = true, - }) async { - if (!isEnabled()) { - _logger.info('Trakt integration is not enabled'); - return meta; - } - - try { - if (meta.type == "series") { - final List body = await _makeRequest( - "$_baseUrl/sync/playback/episodes", - bypassCache: bypassCache, - ); - - for (final item in body) { - final isCurrentShow = - item["show"]?["ids"]?["imdb"] == (meta.imdbId ?? meta.id); - - if (isCurrentShow == false) { - continue; - } - - meta.videos = meta.videos ?? []; - - final result = meta.videos?.firstWhereOrNull((video) { - if (video.tvdbId != null && - item['episode']['ids']['tvdb'] != null) { - return video.tvdbId == item['episode']['ids']['tvdb']; - } - - return video.season == item['season'] && - video.number == item['number']; - }); - - if (result == null) { - continue; - } - - final videoIndex = meta.videos!.indexOf(result); - - meta.videos![videoIndex].progress = item['progress']; - - _logger.info( - "Setting progress for ${meta.videos![videoIndex].name} to ${item['progress']}", - ); - } - return meta; - } else { - final body = await _makeRequest( - "$_baseUrl/sync/playback/movies", - bypassCache: true, - ); - - for (final item in body) { - if (item["type"] != "movie") { - continue; - } - - if (item["movie"]["ids"]["imdb"] == (meta.imdbId ?? meta.id)) { - return meta.copyWith( - progress: item["progress"], - ); - } - } - } - } catch (e) { - _logger.severe('Error fetching progress: $e'); - return meta; - } - - return meta; - } -} diff --git a/lib/features/trakt/types/common.dart b/lib/features/trakt/types/common.dart deleted file mode 100644 index 492fffd..0000000 --- a/lib/features/trakt/types/common.dart +++ /dev/null @@ -1,15 +0,0 @@ -class TraktProgress { - final String id; - final int? episode; - final int? season; - final double progress; - final int? traktId; - - TraktProgress({ - required this.id, - this.episode, - this.season, - required this.progress, - this.traktId, - }); -} diff --git a/lib/features/video_player/container/options/always_on_top.dart b/lib/features/video_player/container/options/always_on_top.dart new file mode 100644 index 0000000..8ef2a6f --- /dev/null +++ b/lib/features/video_player/container/options/always_on_top.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:media_kit_video/media_kit_video.dart'; +import 'package:window_manager/window_manager.dart'; + +class AlwaysOnTopButton extends StatefulWidget { + const AlwaysOnTopButton({super.key}); + + @override + State createState() => _AlwaysOnTopButtonState(); +} + +class _AlwaysOnTopButtonState extends State { + bool alwaysOnTop = false; + + @override + void initState() { + super.initState(); + + windowManager.isAlwaysOnTop().then((value) { + if (mounted) { + setState(() { + alwaysOnTop = value; + }); + } + }); + } + + @override + void dispose() { + super.dispose(); + + windowManager.setAlwaysOnTop(false); + windowManager.setTitleBarStyle(TitleBarStyle.normal); + setState(() { + alwaysOnTop = false; + }); + windowManager.setVisibleOnAllWorkspaces(false); + } + + @override + Widget build(BuildContext context) { + return Tooltip( + message: "Always on top", + child: MaterialDesktopCustomButton( + onPressed: () async { + if (await windowManager.isAlwaysOnTop()) { + windowManager.setAlwaysOnTop(false); + windowManager.setTitleBarStyle(TitleBarStyle.normal); + setState(() { + alwaysOnTop = false; + }); + windowManager.setVisibleOnAllWorkspaces(false); + } else { + windowManager.setAlwaysOnTop(true); + windowManager.setVisibleOnAllWorkspaces(true); + windowManager.setTitleBarStyle(TitleBarStyle.hidden); + setState(() { + alwaysOnTop = true; + }); + } + }, + icon: Icon( + alwaysOnTop ? Icons.push_pin : Icons.push_pin_outlined, + ), + iconSize: 22, + ), + ); + } +} diff --git a/lib/features/video_player/container/options/audio_track_selector.dart b/lib/features/video_player/container/options/audio_track_selector.dart new file mode 100644 index 0000000..69c697c --- /dev/null +++ b/lib/features/video_player/container/options/audio_track_selector.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:media_kit_video/media_kit_video.dart'; + +import '../../../settings/service/playback_setting_service.dart'; + +class AudioTrackSelector extends StatefulWidget { + final VideoController controller; + const AudioTrackSelector({ + super.key, + required this.controller, + }); + + @override + State createState() => _AudioTrackSelectorState(); +} + +class _AudioTrackSelectorState extends State { + final languages = PlaybackSettingsService.instance.getLanguages(); + + String getTitle(AudioTrack trakt, Map? data) { + if (trakt.id == "auto") { + return "Automatic"; + } + + if (trakt.id == "no") { + return "No subtitles"; + } + + final result = trakt.language ?? trakt.id; + + return data?.containsKey(result) == true ? data![result]! : result; + } + + @override + Widget build(BuildContext context) { + final tracks = widget.controller.player.state.tracks.audio; + + return FutureBuilder( + future: languages, + builder: (context, state) { + if (state.connectionState != ConnectionState.done) { + return const CircularProgressIndicator(); + } + + return ListView.builder( + shrinkWrap: true, + itemCount: tracks.length, + itemBuilder: (context, index) { + final track = tracks[index]; + + String trackTitle = ""; + + if (track.codec != null) { + trackTitle += " codec: ${track.codec}"; + } + + if (track.channels != null) { + trackTitle += " channels: ${track.channels}"; + } + + if (track.bitrate != null) { + trackTitle += " bitrate: ${track.bitrate}"; + } + + return ListTile( + title: Text(getTitle(track, state.data)), + subtitle: + trackTitle.trim() != "" ? Text(trackTitle.trim()) : null, + selected: + widget.controller.player.state.track.audio.id == track.id, + onTap: () { + widget.controller.player.setAudioTrack(track); + Navigator.pop(context); + }, + ); + }, + ); + }, + ); + } +} diff --git a/lib/features/video_player/container/options/delay_controls.dart b/lib/features/video_player/container/options/delay_controls.dart new file mode 100644 index 0000000..7e9568d --- /dev/null +++ b/lib/features/video_player/container/options/delay_controls.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../state/video_settings.dart'; + +class DelayControls extends StatelessWidget { + const DelayControls({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, settings, _) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + const Text('Subtitle Delay: '), + Expanded( + child: Slider( + value: settings.subtitleDelay, + min: -50.0, + max: 50.0, + onChanged: (value) { + settings.setSubtitleDelay(value); + }, + ), + ), + Text('${settings.subtitleDelay.toStringAsFixed(1)}s'), + ], + ), + Row( + children: [ + const Text('Audio Delay: '), + Expanded( + child: Slider( + value: settings.audioDelay, + min: -50.0, + max: 50.0, + onChanged: (value) { + settings.setAudioDelay(value); + }, + ), + ), + Text('${settings.audioDelay.toStringAsFixed(1)}s'), + ], + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/features/video_player/container/options/playback_speed.dart b/lib/features/video_player/container/options/playback_speed.dart new file mode 100644 index 0000000..ae2381c --- /dev/null +++ b/lib/features/video_player/container/options/playback_speed.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:media_kit_video/media_kit_video.dart'; + +class PlaybackSpeed extends StatefulWidget { + final VideoController controller; + const PlaybackSpeed({ + super.key, + required this.controller, + }); + + @override + State createState() => _PlaybackSpeedState(); +} + +class _PlaybackSpeedState extends State { + @override + Widget build(BuildContext context) { + final speeds = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0]; + + return ListView.builder( + shrinkWrap: true, + itemCount: speeds.length, + itemBuilder: (context, index) { + return ListTile( + title: Text( + '${speeds[index]}x', + style: TextStyle( + color: Colors.white, + fontWeight: widget.controller.player.state.rate == speeds[index] + ? FontWeight.bold + : FontWeight.normal, + ), + ), + trailing: widget.controller.player.state.rate == speeds[index] + ? const Icon(Icons.check) + : null, + onTap: () { + setState(() { + widget.controller.player.setRate(speeds[index]); + }); + Navigator.pop(context); + }, + ); + }, + ); + } +} diff --git a/lib/features/video_player/container/options/scale_option.dart b/lib/features/video_player/container/options/scale_option.dart new file mode 100644 index 0000000..34914a1 --- /dev/null +++ b/lib/features/video_player/container/options/scale_option.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; +import 'package:media_kit_video/media_kit_video.dart'; +import 'package:provider/provider.dart'; + +import '../state/video_settings.dart'; + +class ScaleOption extends StatelessWidget { + const ScaleOption({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, data, _) { + return MaterialCustomButton( + onPressed: () { + data.toggleFilled(); + }, + icon: Icon( + data.isFilled ? Icons.fit_screen : Icons.fit_screen_outlined, + ), + ); + }, + ); + } +} diff --git a/lib/features/video_player/container/options/settings_sheet.dart b/lib/features/video_player/container/options/settings_sheet.dart new file mode 100644 index 0000000..d1c01fa --- /dev/null +++ b/lib/features/video_player/container/options/settings_sheet.dart @@ -0,0 +1,193 @@ +import 'package:flutter/material.dart'; +import 'package:madari_client/features/video_player/container/options/playback_speed.dart'; +import 'package:madari_client/features/video_player/container/options/subtitle_selector.dart'; +import 'package:madari_client/features/video_player/container/options/subtitle_size.dart'; +import 'package:madari_client/features/video_player/container/options/subtitle_stylesheet.dart'; +import 'package:media_kit_video/media_kit_video.dart'; +import 'package:provider/provider.dart'; + +import '../state/video_settings.dart'; +import 'audio_track_selector.dart'; +import 'delay_controls.dart'; + +class SettingsSheet extends StatefulWidget { + final VideoController controller; + const SettingsSheet({ + super.key, + required this.controller, + }); + + @override + State createState() => _SettingsSheetState(); +} + +class _SettingsSheetState extends State { + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, value, _) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 20), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildSettingsOption( + icon: Icons.speed, + title: 'Playback Speed', + subtitle: '${widget.controller.player.state.rate}x', + onTap: () => _showSpeedSelector(context), + ), + _buildSettingsOption( + icon: Icons.closed_caption, + title: 'Subtitles', + subtitle: 'English', + onTap: () => _showSubtitleSelector(context), + ), + _buildSettingsOption( + icon: Icons.format_size, + title: 'Subtitle Size', + subtitle: '${(value.subtitleSize * 100).round()}%', + onTap: () => _showSubtitleSizeControls(context), + ), + _buildSettingsOption( + icon: Icons.closed_caption, + title: 'Subtitle style', + subtitle: 'Change subtitles and style', + onTap: () => _showSubtitleStyleSheet(context), + ), + _buildSettingsOption( + icon: Icons.audiotrack, + title: 'Audio', + subtitle: 'Original', + onTap: () => _showAudioTrackSelector(context), + ), + _buildSettingsOption( + icon: Icons.timer, + title: 'Timing', + subtitle: 'Adjust delays', + onTap: () => _showDelayControls(context), + ), + ], + ), + ), + ); + }, + ); + } + + Widget _buildSettingsOption({ + required IconData icon, + required String title, + required String subtitle, + required VoidCallback onTap, + }) { + return ListTile( + leading: Icon(icon, color: Colors.white70), + title: Text( + title, + style: + const TextStyle(color: Colors.white, fontWeight: FontWeight.w500), + ), + subtitle: Text( + subtitle, + style: const TextStyle(color: Colors.white70), + ), + trailing: const Icon(Icons.chevron_right, color: Colors.white70), + onTap: onTap, + ); + } + + Future _showCustomBottomSheet({ + required BuildContext context, + required String title, + required Widget child, + }) { + return showModalBottomSheet( + context: context, + backgroundColor: Colors.black87, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Text( + title, + style: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + const Divider(color: Colors.white24), + Flexible(child: child), + ], + ), + ); + } + + void _showSpeedSelector(BuildContext context) async { + await _showCustomBottomSheet( + context: context, + title: 'Playback Speed', + child: PlaybackSpeed( + controller: widget.controller, + ), + ); + + if (context.mounted) Navigator.of(context).pop(); + } + + void _showAudioTrackSelector(BuildContext context) { + _showCustomBottomSheet( + context: context, + title: "Audio Tracks", + child: AudioTrackSelector( + controller: widget.controller, + ), + ); + } + + void _showSubtitleSizeControls(BuildContext context) async { + Navigator.of(context).pop(); + + _showCustomBottomSheet( + context: context, + title: "Subtitle Size", + child: const SubtitleSize(), + ); + } + + void _showSubtitleStyleSheet(BuildContext context) { + _showCustomBottomSheet( + context: context, + title: "Subtitle Style", + child: const SubtitleStylesheet(), + ); + } + + void _showDelayControls(BuildContext context) async { + Navigator.of(context).pop(); + + _showCustomBottomSheet( + context: context, + title: "Delay Controls", + child: const DelayControls(), + ); + } + + void _showSubtitleSelector(BuildContext context) { + _showCustomBottomSheet( + title: "Subtitles", + context: context, + child: SubtitleSelector( + controller: widget.controller, + ), + ); + } +} diff --git a/lib/features/video_player/container/options/subtitle_selector.dart b/lib/features/video_player/container/options/subtitle_selector.dart new file mode 100644 index 0000000..c0fe0c0 --- /dev/null +++ b/lib/features/video_player/container/options/subtitle_selector.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:media_kit_video/media_kit_video.dart'; + +import '../../../settings/service/playback_setting_service.dart'; + +class SubtitleSelector extends StatefulWidget { + final VideoController controller; + + const SubtitleSelector({ + super.key, + required this.controller, + }); + + @override + State createState() => _SubtitleSelectorState(); +} + +class _SubtitleSelectorState extends State { + final languages = PlaybackSettingsService.instance.getLanguages(); + + String getTitle(SubtitleTrack trakt, Map? data) { + if (trakt.id == "auto") { + return "Automatic"; + } + + if (trakt.id == "no") { + return "No subtitles"; + } + + final result = trakt.language ?? trakt.id; + + final returnValue = + data?.containsKey(result) == true ? data![result]! : result; + + return "$returnValue ${(trakt.title ?? "").trim() == "" ? "" : "(${trakt.title?.trim()})"}" + .trim(); + } + + @override + Widget build(BuildContext context) { + final tracks = widget.controller.player.state.tracks.subtitle; + + return FutureBuilder( + future: languages, + builder: (context, state) { + return ListView.builder( + shrinkWrap: true, + itemCount: tracks.length, + itemBuilder: (context, index) { + final track = tracks[index]; + + return ListTile( + title: Text(getTitle(track, state.data)), + selected: + widget.controller.player.state.track.subtitle.id == track.id, + onTap: () { + widget.controller.player.setSubtitleTrack(track); + Navigator.pop(context); + }, + ); + }, + ); + }, + ); + } +} diff --git a/lib/features/video_player/container/options/subtitle_size.dart b/lib/features/video_player/container/options/subtitle_size.dart new file mode 100644 index 0000000..36d79e4 --- /dev/null +++ b/lib/features/video_player/container/options/subtitle_size.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../state/video_settings.dart'; + +class SubtitleSize extends StatefulWidget { + const SubtitleSize({ + super.key, + }); + + @override + State createState() => _SubtitleSizeState(); +} + +class _SubtitleSizeState extends State { + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, settings, _) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + const Text( + 'Size: ', + style: TextStyle(color: Colors.white), + ), + Expanded( + child: Slider( + value: settings.subtitleSize, + min: 0.5, + max: 2.0, + divisions: 6, + onChanged: (value) { + setState(() { + settings.setSubtitleSize(value); + }); + }, + ), + ), + Text( + '${(settings.subtitleSize * 100).round()}%', + style: const TextStyle(color: Colors.white), + ), + ], + ), + const SizedBox(height: 16), + // Preview text + Text( + 'Preview Text', + style: TextStyle( + color: Colors.white, + fontSize: 16.0 * settings.subtitleSize, + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/features/video_player/container/options/subtitle_stylesheet.dart b/lib/features/video_player/container/options/subtitle_stylesheet.dart new file mode 100644 index 0000000..9a9bd36 --- /dev/null +++ b/lib/features/video_player/container/options/subtitle_stylesheet.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../state/video_settings.dart'; + +class SubtitleStylesheet extends StatelessWidget { + const SubtitleStylesheet({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, settings, _) { + return StatefulBuilder( + builder: (context, setState) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Background Color', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + Colors.black, + Colors.grey[900]!, + Colors.grey[800]!, + Colors.grey[700]!, + Colors.blue[900]!, + Colors.brown[900]!, + ].map((color) { + return Padding( + padding: const EdgeInsets.only(right: 8.0), + child: InkWell( + onTap: () { + settings.setSubtitleBackgroundColor(color); + }, + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: color, + border: Border.all( + color: + settings.subtitleBackgroundColor == color + ? Colors.white + : Colors.transparent, + width: 2, + ), + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ); + }).toList(), + ), + ), + const SizedBox(height: 16), + Row( + children: [ + const Text( + 'Background Opacity', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 8), + Text( + '${(settings.subtitleOpacity * 100).round()}%', + style: const TextStyle( + color: Colors.white70, + fontSize: 14, + ), + ), + ], + ), + Slider( + value: settings.subtitleOpacity, + min: 0.0, + max: 1.0, + divisions: 10, + onChanged: (value) { + settings.setSubtitleOpacity(value); + }, + ), + const SizedBox(height: 16), + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.grey[900], + borderRadius: BorderRadius.circular(8), + ), + child: Center( + child: Text( + 'Sample Subtitle Text', + style: TextStyle( + color: Colors.white, + fontSize: 16, + backgroundColor: + settings.subtitleBackgroundColor.withValues( + alpha: settings.subtitleOpacity, + ), + ), + ), + ), + ), + ], + ), + ); + }, + ); + }, + ); + } +} diff --git a/lib/features/video_player/container/state/video_settings.dart b/lib/features/video_player/container/state/video_settings.dart new file mode 100644 index 0000000..4c39fed --- /dev/null +++ b/lib/features/video_player/container/state/video_settings.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; + +class VideoSettingsProvider extends ChangeNotifier { + double _subtitleSize = 1.0; + double _subtitleDelay = 0.0; + double _audioDelay = 0.0; + bool _isLocked = false; + Color _subtitleBackgroundColor = Colors.black; + double _subtitleOpacity = 0.6; + bool _isFilled = false; + + double get subtitleSize => _subtitleSize; + double get subtitleDelay => _subtitleDelay; + double get audioDelay => _audioDelay; + bool get isLocked => _isLocked; + Color get subtitleBackgroundColor => _subtitleBackgroundColor; + double get subtitleOpacity => _subtitleOpacity; + bool get isFilled => _isFilled; + + void setSubtitleSize(double size) { + _subtitleSize = size; + notifyListeners(); + } + + void setIsFilled(bool isFilled) { + _isFilled = isFilled; + notifyListeners(); + } + + void toggleFilled() { + _isFilled = !_isFilled; + notifyListeners(); + } + + void setSubtitleDelay(double delay) { + _subtitleDelay = delay; + notifyListeners(); + } + + void setAudioDelay(double delay) { + _audioDelay = delay; + notifyListeners(); + } + + void setIsLocked(bool locked) { + _isLocked = locked; + notifyListeners(); + } + + void toggleLock() { + _isLocked = !_isLocked; + notifyListeners(); + } + + void setSubtitleBackgroundColor(Color color) { + _subtitleBackgroundColor = color; + notifyListeners(); + } + + void setSubtitleOpacity(double opacity) { + _subtitleOpacity = opacity; + notifyListeners(); + } +} diff --git a/lib/features/video_player/container/video_desktop.dart b/lib/features/video_player/container/video_desktop.dart new file mode 100644 index 0000000..44a589c --- /dev/null +++ b/lib/features/video_player/container/video_desktop.dart @@ -0,0 +1,200 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:madari_client/features/video_player/container/options/settings_sheet.dart'; +import 'package:madari_client/features/video_player/container/state/video_settings.dart'; +import 'package:media_kit_video/media_kit_video.dart'; +import 'package:provider/provider.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import '../../streamio_addons/models/stremio_base_types.dart' as types; +import 'options/always_on_top.dart'; +import 'options/audio_track_selector.dart'; +import 'options/scale_option.dart'; +import 'options/subtitle_selector.dart'; + +class VideoDesktop extends StatefulWidget { + final VideoController controller; + final types.Meta? meta; + + const VideoDesktop({ + super.key, + required this.controller, + required this.meta, + }); + + @override + State createState() => _VideoDesktopState(); +} + +class _VideoDesktopState extends State { + bool isLocked = false; + + @override + void initState() { + super.initState(); + } + + void _toggleLock(BuildContext context) { + final settings = context.read(); + settings.toggleLock(); + } + + Future _showPopupMenu({ + required BuildContext context, + required String title, + required Widget child, + }) { + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(title), + content: SizedBox( + width: 400, // Fixed width for desktop + child: child, + ), + backgroundColor: Colors.black87, + titleTextStyle: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ); + } + + void _showSubtitleSelector(BuildContext context) { + _showPopupMenu( + title: "Subtitles", + context: context, + child: SubtitleSelector( + controller: widget.controller, + ), + ); + } + + void _showSettingsDialog(BuildContext context) { + if (isLocked) return; + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text("Settings"), + content: SizedBox( + width: 400, + child: SettingsSheet( + controller: widget.controller, + ), + ), + backgroundColor: Colors.black87, + titleTextStyle: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ); + } + + void _showAudioTrackSelector(BuildContext context) { + _showPopupMenu( + context: context, + title: "Audio Tracks", + child: AudioTrackSelector( + controller: widget.controller, + ), + ); + } + + MaterialDesktopVideoControlsThemeData getDesktopControls() { + return MaterialDesktopVideoControlsThemeData( + displaySeekBar: true, + hideMouseOnControlsRemoval: true, + toggleFullscreenOnDoublePress: true, + modifyVolumeOnScroll: false, + controlsHoverDuration: const Duration(seconds: 3), + controlsTransitionDuration: const Duration(milliseconds: 300), + seekBarMargin: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 0, + ), + topButtonBar: [ + MaterialCustomButton( + onPressed: isLocked ? () {} : () => context.pop(), + icon: Icon( + Icons.arrow_back, + color: isLocked ? Colors.grey : Colors.white, + ), + ), + const SizedBox( + width: 6, + ), + if (widget.meta?.currentVideo != null) + Expanded( + child: Text( + "${widget.meta?.name} - ${widget.meta?.currentVideo?.name ?? widget.meta?.currentVideo?.title} - S${widget.meta!.currentVideo?.season} E${widget.meta?.currentVideo?.episode}", + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (widget.meta?.currentVideo == null) + Expanded( + child: Text( + widget.meta?.name ?? "", + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + seekBarThumbColor: Theme.of(context).primaryColorLight, + seekBarColor: Theme.of(context).primaryColor, + seekBarPositionColor: Theme.of(context).focusColor, + bottomButtonBar: [ + const MaterialPlayOrPauseButton(), + const MaterialDesktopVolumeButton(), + const MaterialSkipNextButton(), + const SizedBox(width: 12), + const MaterialPositionIndicator(), + const Spacer(), + MaterialCustomButton( + onPressed: () => _showSubtitleSelector(context), + icon: const Icon(Icons.subtitles, color: Colors.white), + ), + MaterialCustomButton( + onPressed: () => _showAudioTrackSelector(context), + icon: const Icon(Icons.audiotrack, color: Colors.white), + ), + if (UniversalPlatform.isDesktop) const AlwaysOnTopButton(), + const ScaleOption(), + MaterialCustomButton( + onPressed: () => _showSettingsDialog(context), + icon: const Icon(Icons.settings, color: Colors.white), + ), + const MaterialFullscreenButton(), + ], + ); + } + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, data, _) { + return MaterialDesktopVideoControlsTheme( + normal: getDesktopControls(), + fullscreen: getDesktopControls(), + child: Video( + controller: widget.controller, + fit: data.isFilled ? BoxFit.fitWidth : BoxFit.fitHeight, + ), + ); + }, + ); + } +} diff --git a/lib/features/video_player/container/video_mobile.dart b/lib/features/video_player/container/video_mobile.dart new file mode 100644 index 0000000..0c5962a --- /dev/null +++ b/lib/features/video_player/container/video_mobile.dart @@ -0,0 +1,222 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:madari_client/features/video_player/container/options/settings_sheet.dart'; +import 'package:madari_client/features/video_player/container/state/video_settings.dart'; +import 'package:media_kit_video/media_kit_video.dart'; +import 'package:provider/provider.dart'; + +import '../../streamio_addons/models/stremio_base_types.dart' as types; +import 'options/audio_track_selector.dart'; +import 'options/scale_option.dart'; +import 'options/subtitle_selector.dart'; + +class VideoMobile extends StatefulWidget { + final VideoController controller; + final types.Meta? meta; + + const VideoMobile({ + super.key, + required this.controller, + required this.meta, + }); + + @override + State createState() => _VideoMobileState(); +} + +class _VideoMobileState extends State { + late final GlobalKey key = GlobalKey(); + bool isLocked = false; + + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + key.currentState?.enterFullscreen(); + }); + } + + void _toggleLock(BuildContext context) { + final settings = context.read(); + settings.toggleLock(); + } + + Future _showCustomBottomSheet({ + required BuildContext context, + required String title, + required Widget child, + }) { + return showModalBottomSheet( + context: context, + backgroundColor: Colors.black87, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Text( + title, + style: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + const Divider(color: Colors.white24), + Flexible(child: child), + ], + ), + ); + } + + void _showSubtitleSelector(BuildContext context) { + _showCustomBottomSheet( + title: "Subtitles", + context: context, + child: SubtitleSelector( + controller: widget.controller, + ), + ); + } + + void _showSettingsSheet(BuildContext context) { + if (isLocked) return; + + showModalBottomSheet( + context: context, + backgroundColor: Colors.black87, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) => SettingsSheet( + controller: widget.controller, + ), + ); + } + + void _showAudioTrackSelector(BuildContext context) { + _showCustomBottomSheet( + context: context, + title: "Audio Tracks", + child: AudioTrackSelector( + controller: widget.controller, + ), + ); + } + + MaterialVideoControlsThemeData getFullscreenControl() { + return kDefaultMaterialVideoControlsThemeDataFullscreen.copyWith( + volumeGesture: !isLocked, + brightnessGesture: !isLocked, + seekGesture: !isLocked, + gesturesEnabledWhileControlsVisible: false, + speedUpOnLongPress: !isLocked, + speedUpFactor: 2, + controlsHoverDuration: const Duration(seconds: 3), + controlsTransitionDuration: const Duration(milliseconds: 300), + seekBarMargin: const EdgeInsets.only( + bottom: 34, + left: 34, + right: 24, + ), + topButtonBar: [ + MaterialCustomButton( + onPressed: isLocked + ? () {} + : () { + Navigator.of(context, rootNavigator: true).pop(); + }, + icon: Icon( + Icons.arrow_back, + color: isLocked ? Colors.grey : Colors.white, + ), + ), + if (widget.meta?.currentVideo != null) + Expanded( + child: Text( + "${widget.meta?.name} - ${widget.meta?.currentVideo?.name ?? widget.meta?.currentVideo?.title} - S${widget.meta!.currentVideo?.season} E${widget.meta?.currentVideo?.episode}", + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (widget.meta?.currentVideo == null) + Expanded( + child: Text( + widget.meta?.name ?? "", + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + overflow: TextOverflow.ellipsis, + ), + ), + MaterialCustomButton( + onPressed: () => _toggleLock(context), + icon: Icon( + isLocked ? Icons.lock : Icons.lock_open, + color: Colors.white, + ), + ), + ], + seekBarThumbColor: Theme.of(context).primaryColorLight, + seekBarColor: Theme.of(context).primaryColor, + seekBarPositionColor: Theme.of(context).focusColor, + bottomButtonBar: [ + const MaterialPlayOrPauseButton(), + const MaterialSkipNextButton(), + const SizedBox(width: 12), + const MaterialPositionIndicator(), + const Spacer(), + MaterialCustomButton( + onPressed: () => _showSubtitleSelector(context), + icon: const Icon(Icons.subtitles, color: Colors.white), + ), + MaterialCustomButton( + onPressed: () => _showAudioTrackSelector(context), + icon: const Icon(Icons.audiotrack, color: Colors.white), + ), + const ScaleOption(), + MaterialCustomButton( + onPressed: () => _showSettingsSheet(context), + icon: const Icon(Icons.settings, color: Colors.white), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, data, _) { + return MaterialVideoControlsTheme( + fullscreen: getFullscreenControl(), + normal: const MaterialVideoControlsThemeData(), + child: Video( + key: key, + onEnterFullscreen: () async { + await defaultEnterNativeFullscreen(); + }, + onExitFullscreen: () async { + await defaultExitNativeFullscreen(); + context.pop(); + }, + controller: widget.controller, + fit: data.isFilled ? BoxFit.fitWidth : BoxFit.fitHeight, + ), + ); + }, + ); + } +} diff --git a/lib/features/video_player/container/video_play.dart b/lib/features/video_player/container/video_play.dart new file mode 100644 index 0000000..cd5a19e --- /dev/null +++ b/lib/features/video_player/container/video_play.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:madari_client/features/settings/model/playback_settings_model.dart'; +import 'package:madari_client/features/video_player/container/video_desktop.dart'; +import 'package:madari_client/features/video_player/container/video_mobile.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:media_kit_video/media_kit_video.dart'; +import 'package:universal_platform/universal_platform.dart'; + +import '../../streamio_addons/models/stremio_base_types.dart'; + +class VideoPlay extends StatefulWidget { + final bool enabledHardwareAcceleration; + final String? poster; + final PlaybackSettings? settings; + final Meta? meta; + final int index; + final String stream; + + const VideoPlay({ + super.key, + required this.enabledHardwareAcceleration, + required this.poster, + this.settings, + this.meta, + required void Function(String message) onError, + required this.index, + required this.stream, + }); + + @override + State createState() => _VideoPlayState(); +} + +class _VideoPlayState extends State { + late String stream = widget.stream; + + late final player = Player( + configuration: const PlayerConfiguration( + title: "Madari", + ), + ); + + late final controller = VideoController( + player, + configuration: VideoControllerConfiguration( + enableHardwareAcceleration: widget.enabledHardwareAcceleration, + ), + ); + + @override + void initState() { + super.initState(); + + player.open( + Media( + widget.stream, + httpHeaders: {}, + extras: {}, + ), + ); + + player.play(); + } + + @override + void dispose() { + super.dispose(); + + player.dispose(); + } + + late int selectedVideo = widget.index; + + @override + Widget build(BuildContext context) { + if (UniversalPlatform.isMobile) { + return VideoMobile( + controller: controller, + meta: widget.meta, + ); + } + + return VideoDesktop( + controller: controller, + meta: widget.meta, + ); + } +} diff --git a/lib/features/video_player/container/video_player.dart b/lib/features/video_player/container/video_player.dart new file mode 100644 index 0000000..65e025f --- /dev/null +++ b/lib/features/video_player/container/video_player.dart @@ -0,0 +1,202 @@ +import 'package:cached_query_flutter/cached_query_flutter.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:logging/logging.dart'; +import 'package:madari_client/features/settings/model/playback_settings_model.dart'; +import 'package:madari_client/features/settings/service/playback_setting_service.dart'; +import 'package:madari_client/features/streamio_addons/models/stremio_base_types.dart'; +import 'package:madari_client/features/video_player/container/video_play.dart'; + +class VideoPlayer extends StatefulWidget { + final String stream; + final Meta meta; + final String id; + final String type; + final String? selectedIndex; + + const VideoPlayer({ + super.key, + required this.type, + required this.id, + required this.meta, + required this.stream, + this.selectedIndex, + }); + + @override + State createState() => _VideoPlayerState(); +} + +class _VideoPlayerState extends State with WidgetsBindingObserver { + final _logger = Logger('VideoPlayer'); + + late final Query _playbackSettings; + + bool _isMounted = false; + + String? _errorMessage; + + int get index { + if (widget.selectedIndex == "null" || widget.selectedIndex == "") { + return 0; + } + + return int.tryParse(widget.selectedIndex ?? "0") ?? 0; + } + + @override + void initState() { + super.initState(); + _isMounted = true; + WidgetsBinding.instance.addObserver(this); + + _initializePlayer(); + } + + Future _initializePlayer() async { + try { + _playbackSettings = Query( + key: "video_settings", + queryFn: () => PlaybackSettingsService.instance.getSettings(), + ); + + await SystemChrome.setPreferredOrientations([ + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ]); + + await SystemChrome.setEnabledSystemUIMode( + SystemUiMode.immersiveSticky, + overlays: [], + ); + } catch (e, stackTrace) { + _logger.severe('Error initializing video player', e, stackTrace); + if (_isMounted) { + setState(() => _errorMessage = 'Failed to initialize video player: $e'); + } + } + } + + @override + Widget build(BuildContext context) { + if (_errorMessage != null) { + return Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + _errorMessage!, + style: const TextStyle(color: Colors.red), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () { + setState(() => _errorMessage = null); + _initializePlayer(); + }, + child: const Text('Retry'), + ), + ], + ), + ), + ); + } + + return QueryBuilder( + query: _playbackSettings, + builder: (context, state) { + switch (state.status) { + case QueryStatus.loading: + case QueryStatus.initial: + return const Scaffold( + body: Center( + child: CircularProgressIndicator(), + ), + ); + + case QueryStatus.error: + return Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Error loading settings: ${state.error}', + style: const TextStyle(color: Colors.red), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () => _playbackSettings.refetch(), + child: const Text('Retry'), + ), + ], + ), + ), + ); + + case QueryStatus.success: + return PopScope( + canPop: true, + onPopInvokedWithResult: (didPop, data) async { + if (didPop) { + _handleBackPress(); + } + }, + child: Scaffold( + extendBody: true, + extendBodyBehindAppBar: true, + body: VideoPlay( + stream: widget.stream, + meta: widget.meta, + index: index, + key: ValueKey('${widget.id}_${widget.selectedIndex}'), + enabledHardwareAcceleration: + state.data?.disableHardwareAcceleration != true, + poster: widget.meta.poster, + onError: _handlePlaybackError, + settings: state.data, + ), + ), + ); + } + }, + ); + } + + Future _handleBackPress() async { + await SystemChrome.setEnabledSystemUIMode( + SystemUiMode.manual, + overlays: SystemUiOverlay.values, + ); + await SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + ]); + return true; + } + + void _handlePlaybackError(String message) { + _logger.warning('Playback error: $message'); + if (_isMounted) { + setState(() => _errorMessage = message); + } + } + + @override + void dispose() { + _isMounted = false; + WidgetsBinding.instance.removeObserver(this); + + SystemChrome.setEnabledSystemUIMode( + SystemUiMode.manual, + overlays: SystemUiOverlay.values, + ); + SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + ]); + + super.dispose(); + } +} diff --git a/lib/features/watch_history/service/base_watch_history.dart b/lib/features/watch_history/service/base_watch_history.dart deleted file mode 100644 index 0bfbc51..0000000 --- a/lib/features/watch_history/service/base_watch_history.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -part 'base_watch_history.g.dart'; - -abstract class BaseWatchHistory { - Future> getItemWatchHistory({ - required List ids, - }); - Future saveWatchHistory({ - required WatchHistory history, - }); -} - -class WatchHistoryGetRequest { - final String id; - final String? season; - final String? episode; - - WatchHistoryGetRequest({ - required this.id, - this.season, - this.episode, - }); -} - -@JsonSerializable() -class WatchHistory { - String id; - final String? season; - final String? episode; - final int progress; - final double duration; - - WatchHistory({ - required this.id, - this.season, - this.episode, - required this.progress, - required this.duration, - }); - - Map? toJson() => _$WatchHistoryToJson(this); - - factory WatchHistory.fromJson(Map json) => - _$WatchHistoryFromJson(json); -} diff --git a/lib/features/watch_history/service/zeee_watch_history.dart b/lib/features/watch_history/service/zeee_watch_history.dart deleted file mode 100644 index b333554..0000000 --- a/lib/features/watch_history/service/zeee_watch_history.dart +++ /dev/null @@ -1,255 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:crypto/crypto.dart'; -import 'package:drift/drift.dart'; -import 'package:madari_client/engine/engine.dart'; -import 'package:madari_client/features/watch_history/service/base_watch_history.dart'; -import 'package:pocketbase/src/auth_store.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -import '../../../database/app_database.dart'; - -String calculateSha1(String input) { - // Convert the input string to UTF-8 bytes - List bytes = utf8.encode(input); - - // Create a SHA-256 digest - Digest digest = sha1.convert(bytes); - - // Convert the digest to a hexadecimal string - return digest.toString(); -} - -class ZeeeWatchHistoryStatic { - static ZeeeWatchHistory? service; -} - -class ZeeeWatchHistory extends BaseWatchHistory { - final http = AppEngine.engine.pb.httpClientFactory(); - Timer? _syncTimer; - static const _lastSyncTimeKey = 'watch_history_last_sync_time'; - final _prefs = SharedPreferences.getInstance(); - final db = AppEngine.engine.database; - - late final StreamSubscription _listener; - - Future clear() async { - (await _prefs).remove(_lastSyncTimeKey); - await db.watchHistoryQueries.clearWatchHistory(); - } - - ZeeeWatchHistory() { - _listener = AppEngine.engine.pb.authStore.onChange.listen((auth) { - if (!AppEngine.engine.pb.authStore.isValid) { - return; - } - - _initializeFromServer().then((docs) { - if (_syncTimer != null) { - _syncTimer!.cancel(); - } - // Start periodic sync - _syncTimer = Timer.periodic( - const Duration( - seconds: 60, - ), - (_) => _syncWithServer(), - ); - }); - }); - } - - Future _initializeFromServer() async { - if (!AppEngine.engine.pb.authStore.isValid) { - return; - } - - final db = AppEngine.engine.database; - final collection = AppEngine.engine.pb.collection("watch_history"); - - try { - final lastSyncTime = (await _prefs).getString(_lastSyncTimeKey); - DateTime? lastSync; - if (lastSyncTime != null) { - lastSync = DateTime.tryParse(lastSyncTime); - } - - int page = 1; - const perPage = 50; - bool hasMore = true; - final filter = lastSync != null - ? 'user = "${AppEngine.engine.pb.authStore.record!.id}" && updated >= "${lastSync.toIso8601String()}"' - : 'user = "${AppEngine.engine.pb.authStore.record!.id}"'; - - while (hasMore) { - final records = await collection.getList( - page: page, - perPage: perPage, - filter: filter, - sort: '-updated', // Changed to sort by most recent first - ); - - if (records.items.isEmpty) { - break; - } - - for (final record in records.items) { - // Check if local record exists and compare timestamps - final localRecord = await db.watchHistoryQueries - .getWatchHistoryById(record.data['id']); - final serverUpdatedAt = DateTime.parse(record.updated); - - if (localRecord == null || - (localRecord.updatedAt.isBefore(serverUpdatedAt) && - (localRecord.lastSyncedAt == null || - localRecord.lastSyncedAt!.isBefore(serverUpdatedAt)))) { - await db.watchHistoryQueries.insertOrUpdateWatchHistory( - WatchHistoryTableCompanion.insert( - id: record.data['id'], - originalId: record.data['originalId'], - progress: Value(record.data['progress']), - duration: Value( - record.data['duration'] is double - ? record.data['duration'] - : (record.data['duration'] as int).toDouble(), - ), - season: Value(record.data['season']), - episode: Value(record.data['episode']), - updatedAt: serverUpdatedAt, - lastSyncedAt: Value(DateTime.now()), - ), - ); - } - } - - hasMore = records.items.length >= perPage; - page++; - } - - // Update last sync time - await (await _prefs).setString( - _lastSyncTimeKey, - DateTime.now().toIso8601String(), - ); - } catch (e, stack) { - print('Failed to initialize watch history from server: $e'); - print(stack); - } - } - - @override - Future> getItemWatchHistory({ - required List ids, - }) async { - final db = AppEngine.engine.database; - - final idsMapped = ids.map((item) { - final ids = - "${Uri.encodeComponent(item.id)}:${Uri.encodeComponent(item.season ?? "")}:${Uri.encodeComponent(item.episode ?? "")}"; - return [item, calculateSha1(ids)]; - }).toList(); - - final records = await db.watchHistoryQueries.getWatchHistoryByIds( - idsMapped.map((item) => item[1] as String).toList(), - ); - - return records.map((record) { - final history = WatchHistory( - id: record.originalId, - progress: record.progress, - duration: record.duration, - season: record.season, - episode: record.episode, - ); - return history; - }).toList(); - } - - @override - Future saveWatchHistory({ - required WatchHistory history, - }) async { - final db = AppEngine.engine.database; - final ids = - "${Uri.encodeComponent(history.id)}:${Uri.encodeComponent(history.season ?? "")}:${Uri.encodeComponent(history.episode ?? "")}"; - final documentId = calculateSha1(ids); - - await db.watchHistoryQueries.insertOrUpdateWatchHistory( - WatchHistoryTableCompanion.insert( - id: documentId, - originalId: history.id, - progress: Value(history.progress), - duration: Value(history.duration), - season: Value(history.season), - episode: Value(history.episode), - updatedAt: DateTime.now(), - ), - ); - } - - Future _syncWithServer() async { - final db = AppEngine.engine.database; - if (!AppEngine.engine.pb.authStore.isValid) { - return; - } - - final unsynced = await db.watchHistoryQueries.getUnsyncedRecords(); - final collection = AppEngine.engine.pb.collection("watch_history"); - - for (final record in unsynced) { - try { - try { - final serverRecord = await collection.getOne(record.id); - final serverUpdatedAt = - DateTime.parse(serverRecord.get('updated')); - - if (record.updatedAt.isBefore(serverUpdatedAt)) { - await db.watchHistoryQueries.updateSyncStatus( - record.id, - DateTime.now(), - ); - continue; - } - } catch (e) {} - - if (record.lastSyncedAt == null) { - await collection.create( - body: { - 'id': record.id, - 'originalId': record.originalId, - 'progress': record.progress, - 'duration': record.duration, - 'season': record.season, - 'episode': record.episode, - 'user': AppEngine.engine.pb.authStore.record!.id, - 'updated': record.updatedAt.toIso8601String(), - }, - ); - } else { - await collection.update( - record.id, - body: { - 'progress': record.progress, - 'duration': record.duration, - 'updated': record.updatedAt.toIso8601String(), - }, - ); - } - - await db.watchHistoryQueries.updateSyncStatus( - record.id, - DateTime.now(), - ); - } catch (e, stack) { - print('Failed to sync record ${record.id}: $e'); - print(stack); - } - } - } - - void dispose() { - _syncTimer?.cancel(); - _listener.cancel(); - } -} diff --git a/lib/features/widgetter/interface/widgets.dart b/lib/features/widgetter/interface/widgets.dart new file mode 100644 index 0000000..8fc0ed9 --- /dev/null +++ b/lib/features/widgetter/interface/widgets.dart @@ -0,0 +1,3 @@ +abstract class Refreshable { + Future refresh(); +} diff --git a/lib/features/widgetter/plugin_base.dart b/lib/features/widgetter/plugin_base.dart new file mode 100644 index 0000000..ba99a45 --- /dev/null +++ b/lib/features/widgetter/plugin_base.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; +import 'package:madari_client/features/widgetter/types/home_layout_model.dart'; +import 'package:madari_client/features/widgetter/types/widget_gallery.dart'; + +class PluginRegistry extends ChangeNotifier { + final _logger = Logger('PluginRegistry'); + + static final PluginRegistry _instance = PluginRegistry._internal(); + static PluginRegistry get instance => _instance; + + PluginRegistry._internal(); + + final _plugins = {}; + final _widgetFactories = >{}; + + void registerPlugin(PluginBase plugin) { + _logger.info('Registering plugin: ${plugin.id}'); + _plugins[plugin.id] = plugin; + _widgetFactories[plugin.id] = plugin.widgetFactories; + notifyListeners(); + } + + List getAvailablePlugins() { + _logger.info('Getting available plugins'); + return _plugins.values.toList(); + } + + Widget? buildWidget( + String pluginId, + String widgetType, + Map config, + PluginContext pluginContext, + ) { + final factories = _widgetFactories[pluginId]; + if (factories == null) return null; + + final factory = factories[widgetType]; + if (factory == null) return null; + + return factory( + config, + pluginContext, + ); + } + + void reset() { + _plugins.clear(); + _widgetFactories.clear(); + notifyListeners(); + } + + PluginBase? getPlugin(String pluginId) { + return _plugins[pluginId]; + } +} + +typedef WidgetFactory = Widget Function( + Map config, + PluginContext context, +); + +class PluginContext { + final int index; + final bool hasSearch; + + PluginContext({ + required this.index, + required this.hasSearch, + }); +} + +abstract class PluginBase { + String get id; + String get name; + Map get widgetFactories; + Future> presets(); +} + +class PluginWidget extends StatelessWidget { + final HomeLayoutModel layout; + final _logger = Logger('PluginWidget'); + final PluginContext pluginContext; + + PluginWidget({ + super.key, + required this.layout, + required this.pluginContext, + }); + + @override + Widget build(BuildContext context) { + final widget = PluginRegistry.instance.buildWidget( + layout.pluginId, + layout.type, + layout.config, + pluginContext, + ); + + if (widget == null) { + _logger.warning( + 'No widget found for plugin: ${layout.pluginId}, type: ${layout.type}', + ); + + return const SizedBox.shrink(); + } + + return FocusTraversalGroup( + child: widget, + ); + } +} diff --git a/lib/features/widgetter/plugin_layout.dart b/lib/features/widgetter/plugin_layout.dart new file mode 100644 index 0000000..128bc61 --- /dev/null +++ b/lib/features/widgetter/plugin_layout.dart @@ -0,0 +1,159 @@ +import 'package:cached_query/cached_query.dart'; +import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; +import 'package:madari_client/features/settings/service/selected_profile.dart'; +import 'package:madari_client/features/streamio_addons/extension/query_extension.dart'; +import 'package:madari_client/features/widgetter/plugin_base.dart'; +import 'package:madari_client/features/widgetter/plugins/stremio/widgets/catalog_featured_shimmer.dart'; +import 'package:madari_client/features/widgetter/state/widget_state_provider.dart'; +import 'package:madari_client/features/widgetter/types/home_layout_model.dart'; +import 'package:provider/provider.dart'; + +import '../home/pages/home_page.dart'; +import '../pocketbase/service/pocketbase.service.dart'; + +class LayoutManager extends StatefulWidget { + final bool hasSearch; + + const LayoutManager({ + super.key, + this.hasSearch = false, + }); + + @override + State createState() => LayoutManagerState(); +} + +class LayoutManagerState extends State { + final _logger = Logger('LayoutManager'); + final ScrollController _scrollController = ScrollController(); + List _layouts = []; + List _filteredLayouts = []; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadLayouts(); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + } + + Future refresh() { + return _loadLayouts( + refresh: true, + ); + } + + Future _loadLayouts({ + bool refresh = false, + }) async { + try { + _logger.info('Loading layouts'); + final query = Query( + key: "home_layout_${SelectedProfileService.instance.selectedProfileId}", + config: QueryConfig( + ignoreCacheDuration: refresh, + cacheDuration: const Duration(hours: 8), + refetchDuration: const Duration(minutes: 5), + ), + queryFn: () async { + return await AppPocketBaseService.instance.pb + .collection('home_layout') + .getFullList( + sort: 'order', + filter: + "profiles = '${SelectedProfileService.instance.selectedProfileId}'", + ); + }, + ); + + if (refresh) { + await query.refetch(); + } + + final records = await query.queryFn(); + + setState(() { + _layouts = records + .map((record) => HomeLayoutModel.fromJson(record.toJson())) + .toList(); + _filteredLayouts = _layouts; + _isLoading = false; + }); + } catch (e) { + _logger.severe('Error loading layouts', e); + setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + final search = context.watch().search; + + return RefreshIndicator( + onRefresh: () { + return refresh(); + }, + child: ListenableBuilder( + listenable: PluginRegistry.instance, + builder: (context, _) { + if (_isLoading) { + return const CatalogFeaturedShimmer(); + } + + return Column( + children: [ + if (widget.hasSearch) + const SearchBox( + hintText: 'Search...', + ), + Expanded( + child: CustomScrollView( + controller: _scrollController, + slivers: [ + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final layout = _filteredLayouts[index]; + + if (widget.hasSearch) { + if (!(layout.pluginId == "stremio_catalog" && + layout.type == "catalog_grid")) { + return const SizedBox.shrink(); + } + } + + return PluginWidget( + key: ValueKey( + '${layout.id}_${layout.pluginId}_${layout.type}_${search.trim()}', + ), + layout: layout, + pluginContext: PluginContext( + index: index, + hasSearch: widget.hasSearch, + ), + ); + }, + childCount: _filteredLayouts.length, + ), + ), + ], + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/lib/features/widgetter/plugins/stremio/containers/cast_info.dart b/lib/features/widgetter/plugins/stremio/containers/cast_info.dart new file mode 100644 index 0000000..263db7f --- /dev/null +++ b/lib/features/widgetter/plugins/stremio/containers/cast_info.dart @@ -0,0 +1,430 @@ +import 'package:cached_query_flutter/cached_query_flutter.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:madari_client/features/streamio_addons/service/stremio_addon_service.dart'; +import 'package:madari_client/features/widgetter/plugins/stremio/containers/cast_info_shimmer.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +import '../models/cast_info.dart'; + +class CastInfoLoader extends StatelessWidget { + final String id; + + CastInfoLoader({ + super.key, + required this.id, + }); + + late final Query query = Query( + key: "cast$id", + config: QueryConfig( + cacheDuration: const Duration(days: 30), + refetchDuration: const Duration(days: 7), + ), + queryFn: () { + return StremioAddonService.instance.getPerson(id); + }, + ); + + @override + Widget build(BuildContext context) { + return QueryBuilder( + query: query, + builder: (context, state) { + if (state.status == QueryStatus.error) { + return const Center( + child: Text("Something went wrong"), + ); + } + + if (state.status == QueryStatus.loading || state.data == null) { + return const CastInfoShimmer(); + } + + return CastInfo( + castMember: state.data!, + ); + }, + ); + } +} + +class CastInfo extends StatelessWidget { + final CastMember castMember; + + const CastInfo({ + super.key, + required this.castMember, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Scaffold( + body: CustomScrollView( + slivers: [ + SliverAppBar( + expandedHeight: 280, + floating: false, + pinned: true, + backgroundColor: theme.colorScheme.surface, + flexibleSpace: FlexibleSpaceBar( + background: Stack( + fit: StackFit.expand, + children: [ + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + theme.colorScheme.primaryContainer.withValues( + alpha: 0.8, + ), + theme.colorScheme.surface, + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 120, + height: 120, + decoration: BoxDecoration( + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: theme.shadowColor.withValues(alpha: 0.2), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: ClipOval( + child: castMember.profilePath != null + ? Image.network( + castMember.profilePath!, + fit: BoxFit.cover, + errorBuilder: + (context, error, stackTrace) => + _buildProfilePlaceholder(theme), + ) + : _buildProfilePlaceholder(theme), + ), + ), + const SizedBox(height: 16), + Text( + castMember.name, + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + if (castMember.department != null) ...[ + const SizedBox(height: 4), + Text( + castMember.department!, + style: theme.textTheme.titleMedium?.copyWith( + color: theme.colorScheme.primary, + ), + ), + ], + if (_hasSocialLinks(castMember.socialLinks)) ...[ + const SizedBox(height: 16), + _buildSocialMediaRow(theme), + ], + ], + ), + ), + ], + ), + ), + ), + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + sliver: SliverList( + delegate: SliverChildListDelegate([ + if (castMember.character != null) _buildCharacterCard(theme), + if (castMember.birthDate != null || + castMember.birthPlace != null) + _buildPersonalInfo(theme), + if (castMember.knownFor.isNotEmpty) _buildKnownFor(theme), + if (castMember.biography != null) _buildBiography(theme), + const SizedBox(height: 24), + ]), + ), + ), + ], + ), + ); + } + + Widget _buildProfilePlaceholder(ThemeData theme) { + return Container( + color: theme.colorScheme.primary.withValues(alpha: 0.1), + child: Icon( + Icons.person, + size: 48, + color: theme.colorScheme.primary, + ), + ); + } + + Widget _buildSocialMediaRow(ThemeData theme) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (castMember.socialLinks.instagram != null) + _buildSocialIcon( + Icons.photo_camera, + 'Instagram', + theme, + castMember.socialLinks.instagram, + ), + if (castMember.socialLinks.twitter != null) + _buildSocialIcon( + Icons.chat, + 'Twitter', + theme, + castMember.socialLinks.twitter, + ), + if (castMember.socialLinks.facebook != null) + _buildSocialIcon( + Icons.facebook, + 'Facebook', + theme, + castMember.socialLinks.facebook, + ), + if (castMember.socialLinks.website != null) + _buildSocialIcon( + Icons.language, + 'Website', + theme, + castMember.socialLinks.website, + ), + ], + ); + } + + Widget _buildSocialIcon( + IconData icon, String label, ThemeData theme, String? url) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: InkWell( + onTap: () { + if (url != null) launchUrlString(url); + }, + borderRadius: BorderRadius.circular(20), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: theme.shadowColor.withValues(alpha: 0.1), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Icon( + icon, + size: 24, + color: theme.colorScheme.primary, + ), + ), + ), + ); + } + + Widget _buildCharacterCard(ThemeData theme) { + return Padding( + padding: const EdgeInsets.only(top: 16.0, bottom: 8.0), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon( + Icons.theater_comedy, + color: theme.colorScheme.primary, + size: 20, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + castMember.character!, + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onPrimaryContainer, + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildPersonalInfo(ThemeData theme) { + return _buildSection( + title: 'Personal Information', + content: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (castMember.birthDate != null) + _buildInfoRow(Icons.cake_outlined, castMember.birthDate!, theme), + if (castMember.birthPlace != null) + _buildInfoRow( + Icons.location_on_outlined, castMember.birthPlace!, theme), + ], + ), + theme: theme, + ); + } + + Widget _buildBiography(ThemeData theme) { + return _buildSection( + title: 'Biography', + content: Text( + castMember.biography!, + style: theme.textTheme.bodyMedium?.copyWith(height: 1.5), + ), + theme: theme, + ); + } + + Widget _buildKnownFor(ThemeData theme) { + return _buildSection( + title: 'Known For', + content: SizedBox( + height: 190, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: castMember.knownFor.length, + itemBuilder: (context, index) { + final movie = castMember.knownFor[index]; + + return Container( + width: 100, + margin: const EdgeInsets.only(right: 12), + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () { + context.push( + "/meta/${movie.type}/${movie.id}", + extra: { + "meta": movie, + }, + ); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: AspectRatio( + aspectRatio: 2 / 3, + child: movie.poster != null + ? Image.network( + movie.poster!, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => + _buildMoviePlaceholder(theme), + ) + : _buildMoviePlaceholder(theme), + ), + ), + const SizedBox(height: 8), + Text( + movie.name ?? "", + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w500, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ); + }, + ), + ), + theme: theme, + ); + } + + Widget _buildMoviePlaceholder(ThemeData theme) { + return Container( + color: theme.colorScheme.surfaceContainerHighest, + child: Center( + child: Icon( + Icons.movie_outlined, + color: theme.colorScheme.primary, + ), + ), + ); + } + + Widget _buildSection({ + required String title, + required Widget content, + required ThemeData theme, + }) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.primary, + ), + ), + const SizedBox(height: 12), + content, + ], + ), + ); + } + + Widget _buildInfoRow(IconData icon, String value, ThemeData theme) { + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Row( + children: [ + Icon( + icon, + size: 18, + color: theme.colorScheme.primary, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + value, + style: theme.textTheme.bodyMedium, + ), + ), + ], + ), + ); + } + + bool _hasSocialLinks(SocialLinks links) { + return links.instagram != null || + links.twitter != null || + links.facebook != null || + links.website != null; + } +} diff --git a/lib/features/widgetter/plugins/stremio/containers/cast_info_shimmer.dart b/lib/features/widgetter/plugins/stremio/containers/cast_info_shimmer.dart new file mode 100644 index 0000000..3c27c10 --- /dev/null +++ b/lib/features/widgetter/plugins/stremio/containers/cast_info_shimmer.dart @@ -0,0 +1,257 @@ +import 'package:flutter/material.dart'; +import 'package:shimmer/shimmer.dart'; + +class CastInfoShimmer extends StatelessWidget { + const CastInfoShimmer({ + super.key, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final baseColor = + theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.2); + final highlightColor = theme.colorScheme.surfaceContainerHighest; + + return Scaffold( + body: CustomScrollView( + slivers: [ + // Profile Header Shimmer + SliverAppBar( + expandedHeight: 280, + floating: false, + pinned: true, + backgroundColor: theme.colorScheme.surface, + flexibleSpace: FlexibleSpaceBar( + background: Container( + color: + theme.colorScheme.primaryContainer.withValues(alpha: 0.8), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Shimmer.fromColors( + baseColor: baseColor, + highlightColor: highlightColor, + child: Container( + width: 120, + height: 120, + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + ), + ), + const SizedBox(height: 16), + Shimmer.fromColors( + baseColor: baseColor, + highlightColor: highlightColor, + child: Container( + width: 200, + height: 24, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(4), + ), + ), + ), + const SizedBox(height: 8), + Shimmer.fromColors( + baseColor: baseColor, + highlightColor: highlightColor, + child: Container( + width: 120, + height: 16, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(4), + ), + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate( + 4, + (index) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Shimmer.fromColors( + baseColor: baseColor, + highlightColor: highlightColor, + child: Container( + width: 40, + height: 40, + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + + // Content Shimmer + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + sliver: SliverList( + delegate: SliverChildListDelegate([ + const SizedBox(height: 16), + Shimmer.fromColors( + baseColor: baseColor, + highlightColor: highlightColor, + child: Container( + height: 48, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + ), + ), + _buildSectionShimmer( + title: 'Personal Information', + content: Column( + children: List.generate( + 2, + (index) => Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Row( + children: [ + Shimmer.fromColors( + baseColor: baseColor, + highlightColor: highlightColor, + child: Container( + width: 18, + height: 18, + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Shimmer.fromColors( + baseColor: baseColor, + highlightColor: highlightColor, + child: Container( + height: 16, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(4), + ), + ), + ), + ), + ], + ), + ), + ), + ), + theme: theme, + ), + _buildSectionShimmer( + title: 'Biography', + content: Column( + children: List.generate( + 4, + (index) => Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Shimmer.fromColors( + baseColor: baseColor, + highlightColor: highlightColor, + child: Container( + height: 16, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(4), + ), + ), + ), + ), + ), + ), + theme: theme, + ), + _buildSectionShimmer( + title: 'Known For', + content: SizedBox( + height: 190, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: 5, + itemBuilder: (context, index) { + return Container( + width: 100, + margin: const EdgeInsets.only(right: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Shimmer.fromColors( + baseColor: baseColor, + highlightColor: highlightColor, + child: Container( + height: 150, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + ), + ), + ), + const SizedBox(height: 8), + Shimmer.fromColors( + baseColor: baseColor, + highlightColor: highlightColor, + child: Container( + height: 16, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(4), + ), + ), + ), + ], + ), + ); + }, + ), + ), + theme: theme, + ), + const SizedBox(height: 24), + ]), + ), + ), + ], + ), + ); + } + + Widget _buildSectionShimmer({ + required String title, + required Widget content, + required ThemeData theme, + }) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.primary, + ), + ), + const SizedBox(height: 12), + content, + ], + ), + ); + } +} diff --git a/lib/features/widgetter/plugins/stremio/containers/keyboard_handler.dart b/lib/features/widgetter/plugins/stremio/containers/keyboard_handler.dart new file mode 100644 index 0000000..1b06bf8 --- /dev/null +++ b/lib/features/widgetter/plugins/stremio/containers/keyboard_handler.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:logging/logging.dart'; + +final _logger = Logger('StreamioKeyboardHandler'); + +class StreamioKeyboardHandler extends StatelessWidget { + final Widget child; + final FocusNode focusNode; + final VoidCallback? onPlay; + final VoidCallback? onBack; + final VoidCallback? onNextEpisode; + final VoidCallback? onPreviousEpisode; + + const StreamioKeyboardHandler({ + super.key, + required this.child, + required this.focusNode, + this.onPlay, + this.onBack, + this.onNextEpisode, + this.onPreviousEpisode, + }); + + @override + Widget build(BuildContext context) { + return KeyboardListener( + focusNode: focusNode, + onKeyEvent: (KeyEvent event) { + if (event is! KeyDownEvent) return; + + _logger.fine('Key event: ${event.logicalKey}'); + + switch (event.logicalKey) { + case LogicalKeyboardKey.space: + case LogicalKeyboardKey.enter: + if (onPlay != null) { + onPlay!(); + return; + } + break; + case LogicalKeyboardKey.escape: + case LogicalKeyboardKey.backspace: + if (onBack != null) { + onBack!(); + return; + } + break; + case LogicalKeyboardKey.arrowRight: + if (onNextEpisode != null) { + onNextEpisode!(); + return; + } + break; + case LogicalKeyboardKey.arrowLeft: + if (onPreviousEpisode != null) { + onPreviousEpisode!(); + return; + } + break; + } + }, + child: child, + ); + } +} diff --git a/lib/features/widgetter/plugins/stremio/containers/shimmer.dart b/lib/features/widgetter/plugins/stremio/containers/shimmer.dart new file mode 100644 index 0000000..139d809 --- /dev/null +++ b/lib/features/widgetter/plugins/stremio/containers/shimmer.dart @@ -0,0 +1,304 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; +import 'package:shimmer/shimmer.dart'; + +final _logger = Logger('StreamioShimmer'); + +class StreamioShimmer extends StatelessWidget { + final String? image; + final String tag; + + const StreamioShimmer({ + super.key, + this.image, + required this.tag, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final screenSize = MediaQuery.of(context).size; + + return Stack( + children: [ + Container( + width: screenSize.width, + height: screenSize.height, + color: colorScheme.surface, + ), + SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: screenSize.height * 0.65, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + flex: 2, + child: Stack( + children: [ + Container( + constraints: const BoxConstraints( + maxHeight: 220, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: colorScheme.surface, + ), + ), + Hero( + tag: tag, + child: Container( + constraints: const BoxConstraints( + maxHeight: 220, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + image: image != null + ? DecorationImage( + image: CachedNetworkImageProvider( + image!), + fit: BoxFit.cover, + ) + : null, + ), + ), + ), + ], + ), + ), + const SizedBox(width: 16), + Expanded( + flex: 3, + child: Shimmer.fromColors( + baseColor: colorScheme.surfaceContainerHighest, + highlightColor: colorScheme.surface, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + height: 48, + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(height: 8), + Container( + height: 24, + width: 200, + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(height: 16), + Container( + height: 32, + width: 120, + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(4), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + Shimmer.fromColors( + baseColor: colorScheme.surfaceContainerHighest, + highlightColor: colorScheme.surface, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: List.generate( + 3, + (index) => Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Container( + height: 16, + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(4), + ), + ), + ), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 24, + width: 80, + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(height: 16), + SizedBox( + height: 160, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: 6, + itemBuilder: (context, index) => Padding( + padding: const EdgeInsets.only(right: 12), + child: Column( + children: [ + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: colorScheme.surface, + shape: BoxShape.circle, + ), + ), + const SizedBox(height: 8), + Container( + width: 80, + height: 16, + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(height: 4), + Container( + width: 60, + height: 14, + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(4), + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + Shimmer.fromColors( + baseColor: colorScheme.surfaceContainerHighest, + highlightColor: colorScheme.surface, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 24, + width: 100, + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(height: 12), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: List.generate( + 5, + (index) => Container( + width: 100, + height: 40, + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(20), + ), + ), + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + Shimmer.fromColors( + baseColor: colorScheme.surfaceContainerHighest, + highlightColor: colorScheme.surface, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: List.generate( + 4, + (index) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Container( + height: 70, + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + ), + ), + ), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 24, + width: 100, + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(height: 16), + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + childAspectRatio: 16 / 9, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemCount: 4, + itemBuilder: (context, index) => Container( + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 32), + ], + ), + ), + ], + ); + } +} diff --git a/lib/features/widgetter/plugins/stremio/containers/stream_list.dart b/lib/features/widgetter/plugins/stremio/containers/stream_list.dart new file mode 100644 index 0000000..da8abde --- /dev/null +++ b/lib/features/widgetter/plugins/stremio/containers/stream_list.dart @@ -0,0 +1,573 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:logging/logging.dart'; +import 'package:madari_client/features/streamio_addons/extension/query_extension.dart'; + +import '../../../../streamio_addons/models/stremio_base_types.dart'; +import '../../../../streamio_addons/service/stremio_addon_service.dart'; + +final _logger = Logger('StreamioStreamList'); + +class StreamioStreamList extends StatefulWidget { + final Meta meta; + + const StreamioStreamList({ + super.key, + required this.meta, + }); + + @override + State createState() => _StreamioStreamListState(); +} + +class _StreamioStreamListState extends State { + final service = StremioAddonService.instance; + final List streams = []; + + int streamSupportedAddonCount = 0; + + bool _isLoading = true; + Set _selectedResolutions = {}; + Set _selectedQualities = {}; + Set _selectedCodecs = {}; + Set _selectedAudios = {}; + Set _selectedSizes = {}; + final Set _selectedAddons = {}; + + Set _resolutions = {}; + Set _qualities = {}; + Set _codecs = {}; + Set _audios = {}; + Set _sizes = {}; + final Set _addons = {}; + + @override + void initState() { + super.initState(); + _loadStreams(); + } + + void _loadStreams() async { + _logger.info('Loading streams for ${widget.meta.id}'); + + final addons = service.getInstalledAddons(); + + final result = await addons.queryFn(); + + final count = result + .where((res) { + for (final item in (res.resources ?? [])) { + if (item.name == "stream") { + return true; + } + } + + return false; + }) + .toList() + .length; + + int left = count; + + setState(() { + streamSupportedAddonCount = count; + }); + + if (left == 0) { + setState(() { + _isLoading = false; + }); + } + + service.getStreams( + widget.meta, + callback: (items, addonName, error) { + setState(() { + left -= 1; + + if (left <= 0) { + _isLoading = false; + } + }); + + if (error != null) { + _logger.severe('Error loading streams: $error'); + if (mounted) setState(() => _isLoading = false); + return; + } + + if (items != null) { + final Set resSet = {}; + final Set qualSet = {}; + final Set codecSet = {}; + final Set audioSet = {}; + final Set sizeSet = {}; + + final streamsWithAddon = items + .map( + (stream) => StreamWithAddon( + stream: stream, + addonName: addonName, + ), + ) + .toList(); + + for (var streamData in streamsWithAddon) { + if (streamData.stream.name != null) { + try { + final info = + StreamParser.parseStreamName(streamData.stream.name!); + if (info.resolution != null) resSet.add(info.resolution!); + if (info.quality != null) qualSet.add(info.quality!); + if (info.codec != null) codecSet.add(info.codec!); + if (info.audio != null) audioSet.add(info.audio!); + if (info.size != null) { + sizeSet.add(StreamParser.getSizeCategory(info.size)); + } + } catch (e) { + _logger.warning( + 'Error parsing stream name: ${streamData.stream.name}', e); + } + } + } + + if (mounted) { + setState(() { + streams.addAll(streamsWithAddon); + if (addonName != null) _addons.add(addonName); + _resolutions = resSet; + _qualities = qualSet; + _codecs = codecSet; + _audios = audioSet; + _sizes = sizeSet; + _isLoading = false; + }); + } + } + }, + ); + } + + Color _getQualityColor(String resolution) { + final res = resolution.toUpperCase(); + if (res.contains('2160P') || res.contains('4K') || res.contains('UHD')) { + return Colors.amberAccent; + } else if (res.contains('1080P')) { + return Colors.blue; + } else if (res.contains('720P')) { + return Colors.green; + } + return Colors.grey; + } + + List _getFilteredStreams() { + return streams.where((streamData) { + if (streamData.stream.name == null) return false; + + if (_selectedAddons.isNotEmpty && + !_selectedAddons.contains(streamData.addonName)) { + return false; + } + + try { + final info = StreamParser.parseStreamName(streamData.stream.name!); + + bool matchesResolution = _selectedResolutions.isEmpty || + (info.resolution != null && + _selectedResolutions.contains(info.resolution)); + bool matchesQuality = _selectedQualities.isEmpty || + (info.quality != null && _selectedQualities.contains(info.quality)); + bool matchesCodec = _selectedCodecs.isEmpty || + (info.codec != null && _selectedCodecs.contains(info.codec)); + bool matchesAudio = _selectedAudios.isEmpty || + (info.audio != null && _selectedAudios.contains(info.audio)); + bool matchesSize = _selectedSizes.isEmpty || + (info.size != null && + _selectedSizes + .contains(StreamParser.getSizeCategory(info.size))); + + return matchesResolution && + matchesQuality && + matchesCodec && + matchesAudio && + matchesSize; + } catch (e) { + _logger.warning( + 'Error parsing stream info: ${streamData.stream.name}', e); + return false; + } + }).toList(); + } + + Widget _buildFilterChips() { + final theme = Theme.of(context); + + Widget buildFilterGroup(Set options, Set selected, + Function(Set) onChanged) { + if (options.isEmpty) return const SizedBox.shrink(); + + return Wrap( + spacing: 8, + children: options.map((option) { + return FilterChip( + label: Text(option), + labelStyle: TextStyle( + fontSize: 12, + color: selected.contains(option) + ? theme.colorScheme.onPrimary + : theme.colorScheme.onSurface, + ), + selected: selected.contains(option), + showCheckmark: false, + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + selectedColor: theme.colorScheme.primary, + backgroundColor: theme.colorScheme.surfaceContainerHighest, + onSelected: (bool value) { + final newSelection = Set.from(selected); + if (value) { + newSelection.add(option); + } else { + newSelection.remove(option); + } + onChanged(newSelection); + }, + ); + }).toList(), + ); + } + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + buildFilterGroup(_resolutions, _selectedResolutions, + (value) => setState(() => _selectedResolutions = value)), + if (_qualities.isNotEmpty) const SizedBox(width: 8), + buildFilterGroup(_qualities, _selectedQualities, + (value) => setState(() => _selectedQualities = value)), + if (_codecs.isNotEmpty) const SizedBox(width: 8), + buildFilterGroup(_codecs, _selectedCodecs, + (value) => setState(() => _selectedCodecs = value)), + if (_audios.isNotEmpty) const SizedBox(width: 8), + buildFilterGroup(_audios, _selectedAudios, + (value) => setState(() => _selectedAudios = value)), + if (_sizes.isNotEmpty) const SizedBox(width: 8), + buildFilterGroup(_sizes, _selectedSizes, + (value) => setState(() => _selectedSizes = value)), + ], + ), + ); + } + + Widget _buildStreamCard(StreamWithAddon streamData, ThemeData theme) { + final stream = streamData.stream; + final info = StreamParser.parseStreamName(stream.name ?? ''); + + return InkWell( + onTap: stream.url != null + ? () { + if (stream.url != null) { + String url = + '/player/${widget.meta.type}/${widget.meta.id}/${Uri.encodeQueryComponent(stream.url!)}?'; + + final List query = []; + + if (widget.meta.selectedVideoIndex != null) { + query.add("index=${widget.meta.selectedVideoIndex}"); + } + + if (stream.behaviorHints?["bingeGroup"] != null) { + query.add( + "binge-group=${Uri.encodeQueryComponent(stream.behaviorHints?["bingeGroup"])}", + ); + } + + context.push( + url + query.join("&"), + extra: { + "meta": widget.meta, + }, + ); + } + } + : null, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (info.resolution != null) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: StreamTag( + text: info.resolution!, + color: _getQualityColor(info.resolution!), + outlined: true, + ), + ), + Text( + (stream.name ?? 'Unknown Title') + + (stream.url != null ? "" : " (Not supported)"), + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + Text( + stream.title ?? 'Unknown Title', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + if (stream.description != null) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + stream.description!, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + if (streamData.addonName != null) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + 'From: ${streamData.addonName}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + ], + ), + if (info.quality != null && + info.codec != null && + info.audio != null && + info.size != null && + info.unrated) + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + if (info.quality != null) + StreamTag( + text: info.quality!, + color: theme.colorScheme.secondary, + ), + if (info.codec != null) + StreamTag( + text: info.codec!, + color: theme.colorScheme.tertiary, + ), + if (info.audio != null) + StreamTag( + text: info.audio!, + color: theme.colorScheme.primary, + ), + if (info.size != null) + StreamTag( + text: StreamParser.getSizeCategory(info.size), + color: theme.colorScheme.secondary, + ), + if (info.unrated) + StreamTag( + text: 'UNRATED', + color: theme.colorScheme.error, + ), + ], + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } + + final theme = Theme.of(context); + + if (streamSupportedAddonCount == 0) { + return Scaffold( + appBar: AppBar( + title: const Text("No addons"), + ), + body: Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "You have configured no addons for the streaming.", + style: theme.textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox( + height: 6, + ), + Text( + "In order to stream you have to have atleast one addon in order to play.", + style: theme.textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + const SizedBox( + height: 12, + ), + OutlinedButton( + onPressed: () { + context.push('/settings/addons'); + }, + child: const Text( + "Manage Addons", + ), + ), + ], + ), + ), + ), + ); + } + + final filteredStreams = _getFilteredStreams(); + + return Scaffold( + appBar: AppBar( + title: Text("Streams (${filteredStreams.length})"), + actions: [ + PopupMenuButton( + icon: const Icon(Icons.filter_list), + tooltip: 'Filter by addon', + onSelected: (String addon) { + setState(() { + if (_selectedAddons.contains(addon)) { + _selectedAddons.remove(addon); + } else { + _selectedAddons.add(addon); + } + }); + }, + itemBuilder: (BuildContext context) { + return _addons.map((String addon) { + return PopupMenuItem( + value: addon, + child: Row( + children: [ + Checkbox( + value: _selectedAddons.contains(addon), + onChanged: (bool? value) { + setState(() { + if (value == true) { + _selectedAddons.add(addon); + } else { + _selectedAddons.remove(addon); + } + }); + Navigator.pop(context); + }, + ), + Text(addon), + ], + ), + ); + }).toList(); + }, + ), + ], + ), + body: Column( + children: [ + Material( + color: theme.colorScheme.surface, + elevation: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: _buildFilterChips(), + ), + ], + ), + ), + Expanded( + child: ListView.separated( + itemCount: filteredStreams.length, + separatorBuilder: (context, index) => const Divider(height: 1), + itemBuilder: (context, index) { + final streamData = filteredStreams[index]; + return _buildStreamCard(streamData, theme); + }, + ), + ), + ], + ), + ); + } +} + +class StreamWithAddon { + final VideoStream stream; + final String? addonName; + + StreamWithAddon({required this.stream, this.addonName}); +} + +class StreamTag extends StatelessWidget { + final String text; + final Color? color; + final Color? backgroundColor; + final bool outlined; + + const StreamTag({ + super.key, + required this.text, + this.color, + this.backgroundColor, + this.outlined = false, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final effectiveColor = color ?? theme.colorScheme.primary; + final effectiveBgColor = backgroundColor ?? effectiveColor.withAlpha(30); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: outlined ? null : effectiveBgColor, + borderRadius: BorderRadius.circular(4), + border: + outlined ? Border.all(color: effectiveColor.withAlpha(100)) : null, + ), + child: Text( + text, + style: theme.textTheme.labelSmall?.copyWith( + color: effectiveColor, + fontWeight: FontWeight.w500, + ), + ), + ); + } +} diff --git a/lib/features/widgetter/plugins/stremio/containers/streamio_background.dart b/lib/features/widgetter/plugins/stremio/containers/streamio_background.dart new file mode 100644 index 0000000..778510f --- /dev/null +++ b/lib/features/widgetter/plugins/stremio/containers/streamio_background.dart @@ -0,0 +1,765 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; +import 'package:madari_client/features/streamio_addons/models/stremio_base_types.dart'; + +import '../../../../library/container/add_to_list_button.dart'; +import 'stream_list.dart'; + +final _logger = Logger('StreamioComponents'); + +Future openVideoStream(BuildContext context, Meta meta) async { + return showModalBottomSheet( + enableDrag: true, + constraints: const BoxConstraints( + maxWidth: 780, + ), + isScrollControlled: true, + useSafeArea: true, + context: context, + builder: (context) { + return Scaffold( + body: StreamioStreamList( + meta: meta, + ), + ); + }, + ); +} + +class StreamioBackground extends StatelessWidget { + final String? imageUrl; + + const StreamioBackground({super.key, this.imageUrl}); + + @override + Widget build(BuildContext context) { + if (imageUrl == null) return const SizedBox.shrink(); + + return SizedBox.expand( + child: Stack( + fit: StackFit.expand, + children: [ + CachedNetworkImage( + imageUrl: imageUrl!, + fit: BoxFit.cover, + errorWidget: (context, url, error) { + _logger.warning('Error loading background image', error); + return const SizedBox.shrink(); + }, + ), + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withValues(alpha: 0.8), + Colors.black.withValues(alpha: 0.9), + Colors.black, + ], + ), + ), + ), + ], + ), + ); + } +} + +class StreamioHeroSection extends StatelessWidget { + final Meta meta; + final String type; + final String? prefix; + + const StreamioHeroSection({ + super.key, + required this.meta, + required this.type, + this.prefix, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.only( + top: 160, + left: 16.0, + right: 16.0, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (meta.poster != null) + Expanded( + flex: 2, + child: SizedBox( + height: 220, + width: 130, + child: Stack( + alignment: Alignment.center, + children: [ + Hero( + tag: prefix ?? "", + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: CachedNetworkImage( + imageUrl: + "https://proxy-image.syncws.com/insecure/plain/${Uri.encodeQueryComponent(meta.poster!)}@webp", + fit: BoxFit.cover, + errorWidget: (context, url, error) { + _logger.warning( + 'Error loading poster image', error); + return const Icon(Icons.error); + }, + ), + ), + ), + IconButton.filled( + onPressed: () { + _logger.info('Play button pressed for ${meta.name}'); + + openVideoStream(context, meta); + }, + icon: const Icon(Icons.play_arrow, size: 32), + style: IconButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: + Theme.of(context).colorScheme.onPrimary, + ), + ), + ], + ), + ), + ), + Expanded( + flex: 3, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + meta.name ?? 'Unknown Title', + style: Theme.of(context).textTheme.headlineLarge?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + '${meta.year ?? ''} • ${meta.runtime ?? ''} • ${meta.genres?.join(', ') ?? ''}', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Colors.white.withValues(alpha: 0.7), + ), + ), + if (meta.imdbRating.isNotEmpty && + meta.imdbRating.toString() != "null") ...[ + const SizedBox(height: 16), + Row( + children: [ + const Icon(Icons.star, color: Colors.amber), + const SizedBox(width: 8), + Text( + meta.imdbRating, + style: + Theme.of(context).textTheme.titleMedium?.copyWith( + color: Colors.white, + ), + ), + ], + ), + ], + const SizedBox( + height: 12, + ), + AddToListButton( + label: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.playlist_add_outlined), + SizedBox( + width: 8, + ), + Text("Add to list"), + ], + ), + meta: meta, + icon: Icons.add, + ) + ], + ), + ), + ), + ], + ), + ); + } +} + +class StreamioSeasonSelector extends StatelessWidget { + final List