├── lib ├── widgets │ ├── back_button.dart │ ├── custom_button.dart │ ├── custom_list_tile.dart │ ├── custom_slider.dart │ ├── custom_paint.dart │ ├── color_picker.dart │ └── episode_menu.dart ├── models │ ├── service_api │ │ ├── searchepisodes.dart │ │ ├── searchepisodes.g.dart │ │ ├── index_episode.dart │ │ ├── index_episode.g.dart │ │ ├── itunes_podcast.g.dart │ │ ├── index_podcast.g.dart │ │ ├── index_podcast.dart │ │ ├── itunes_podcast.dart │ │ ├── searchpodcast.g.dart │ │ └── searchpodcast.dart │ ├── podcastlocal.dart │ ├── episodebrief.dart │ └── fireside_data.dart ├── main.dart ├── service │ └── search_api.dart ├── storage │ └── key_value_storage.dart ├── utils │ └── extension_helper.dart ├── generated │ └── intl │ │ └── messages_all.dart ├── providers │ ├── audio_state.dart │ ├── downloader.dart │ └── settings_state.dart └── screens │ ├── episode_detail.dart │ ├── playlist_page.dart │ ├── podcasts_page.dart │ ├── home.dart │ └── player.dart ├── linux ├── .gitignore ├── flatpak │ ├── me.stonegate.tsacdop.desktop │ └── me.stonegate.tsacdop.metainfo.xml ├── main.cc ├── flutter │ ├── generated_plugin_registrant.h │ ├── generated_plugins.cmake │ ├── generated_plugin_registrant.cc │ └── CMakeLists.txt ├── my_application.h ├── my_application.cc └── CMakeLists.txt ├── assets ├── fr.png ├── it.png ├── mx.png ├── no.png ├── pt.png ├── logo.png ├── text.png ├── shownote.png ├── text_light.png ├── buymeacoffee.png ├── podcastindex.png ├── localizely_logo.png ├── localizely_logo_light.png └── logo.svg ├── preview ├── episode.png ├── podcast.png ├── search.png ├── homepage.png └── linux_home.png ├── windows ├── runner │ ├── resources │ │ ├── app_icon.ico │ │ └── flutter.ico │ ├── utils.h │ ├── resource.h │ ├── utils.cpp │ ├── CMakeLists.txt │ ├── runner.exe.manifest │ ├── run_loop.h │ ├── main.cpp │ ├── flutter_window.h │ ├── flutter_window.cpp │ ├── run_loop.cpp │ ├── Runner.rc │ ├── win32_window.h │ └── win32_window.cpp ├── .gitignore ├── flutter │ ├── generated_plugin_registrant.h │ ├── generated_plugins.cmake │ ├── generated_plugin_registrant.cc │ └── CMakeLists.txt └── CMakeLists.txt ├── CHANGELOG.md ├── .metadata ├── .analysis_options.yaml ├── .vscode ├── launch.json └── c_cpp_properties.json ├── .github ├── FUNDING.yml └── workflows │ └── linux_build.yml ├── .gitignore ├── test └── widget_test.dart ├── analysis_options.yaml ├── pubspec.yaml └── README.md /lib/widgets/back_button.dart: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /linux/.gitignore: -------------------------------------------------------------------------------- 1 | flutter/ephemeral 2 | -------------------------------------------------------------------------------- /assets/fr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsacdop/tsacdop_desktop/HEAD/assets/fr.png -------------------------------------------------------------------------------- /assets/it.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsacdop/tsacdop_desktop/HEAD/assets/it.png -------------------------------------------------------------------------------- /assets/mx.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsacdop/tsacdop_desktop/HEAD/assets/mx.png -------------------------------------------------------------------------------- /assets/no.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsacdop/tsacdop_desktop/HEAD/assets/no.png -------------------------------------------------------------------------------- /assets/pt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsacdop/tsacdop_desktop/HEAD/assets/pt.png -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsacdop/tsacdop_desktop/HEAD/assets/logo.png -------------------------------------------------------------------------------- /assets/text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsacdop/tsacdop_desktop/HEAD/assets/text.png -------------------------------------------------------------------------------- /assets/shownote.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsacdop/tsacdop_desktop/HEAD/assets/shownote.png -------------------------------------------------------------------------------- /preview/episode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsacdop/tsacdop_desktop/HEAD/preview/episode.png -------------------------------------------------------------------------------- /preview/podcast.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsacdop/tsacdop_desktop/HEAD/preview/podcast.png -------------------------------------------------------------------------------- /preview/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsacdop/tsacdop_desktop/HEAD/preview/search.png -------------------------------------------------------------------------------- /assets/text_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsacdop/tsacdop_desktop/HEAD/assets/text_light.png -------------------------------------------------------------------------------- /preview/homepage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsacdop/tsacdop_desktop/HEAD/preview/homepage.png -------------------------------------------------------------------------------- /preview/linux_home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsacdop/tsacdop_desktop/HEAD/preview/linux_home.png -------------------------------------------------------------------------------- /assets/buymeacoffee.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsacdop/tsacdop_desktop/HEAD/assets/buymeacoffee.png -------------------------------------------------------------------------------- /assets/podcastindex.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsacdop/tsacdop_desktop/HEAD/assets/podcastindex.png -------------------------------------------------------------------------------- /assets/localizely_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsacdop/tsacdop_desktop/HEAD/assets/localizely_logo.png -------------------------------------------------------------------------------- /assets/localizely_logo_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsacdop/tsacdop_desktop/HEAD/assets/localizely_logo_light.png -------------------------------------------------------------------------------- /windows/runner/resources/app_icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsacdop/tsacdop_desktop/HEAD/windows/runner/resources/app_icon.ico -------------------------------------------------------------------------------- /windows/runner/resources/flutter.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsacdop/tsacdop_desktop/HEAD/windows/runner/resources/flutter.ico -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Tsacdop Desktop changelog 2 | 3 | 4 | 5 | ## 0.0.1 (pre-release) 6 | 7 | Release date 2020/10/23 8 | Initial release. 9 | -------------------------------------------------------------------------------- /linux/flatpak/me.stonegate.tsacdop.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Type=Application 3 | Name=Tsacdop 4 | Icon=me.stonegate.tsacdop 5 | Exec=tsacdop 6 | Categories=Audio 7 | Keywords=Podcast;Audio; 8 | -------------------------------------------------------------------------------- /linux/main.cc: -------------------------------------------------------------------------------- 1 | #include "my_application.h" 2 | 3 | int main(int argc, char** argv) { 4 | g_autoptr(MyApplication) app = my_application_new(); 5 | return g_application_run(G_APPLICATION(app), argc, argv); 6 | } 7 | -------------------------------------------------------------------------------- /windows/runner/utils.h: -------------------------------------------------------------------------------- 1 | #ifndef RUNNER_UTILS_H_ 2 | #define RUNNER_UTILS_H_ 3 | 4 | // Creates a console for the process, and redirects stdout and stderr to 5 | // it for both the runner and the Flutter library. 6 | void CreateAndAttachConsole(); 7 | 8 | #endif // RUNNER_UTILS_H_ 9 | -------------------------------------------------------------------------------- /.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: 37ebe3d82a9d5faeda7d3c1a6ad193030210a2cc 8 | channel: dev 9 | 10 | project_type: app 11 | -------------------------------------------------------------------------------- /windows/.gitignore: -------------------------------------------------------------------------------- 1 | flutter/ephemeral/ 2 | 3 | # Visual Studio user-specific files. 4 | *.suo 5 | *.user 6 | *.userosscache 7 | *.sln.docstates 8 | 9 | # Visual Studio build-related files. 10 | x64/ 11 | x86/ 12 | 13 | # Visual Studio cache files 14 | # files ending in .cache can be ignored 15 | *.[Cc]ache 16 | # but keep track of directories ending in .cache 17 | !*.[Cc]ache/ 18 | -------------------------------------------------------------------------------- /linux/flutter/generated_plugin_registrant.h: -------------------------------------------------------------------------------- 1 | // 2 | // Generated file. Do not edit. 3 | // 4 | 5 | // clang-format off 6 | 7 | #ifndef GENERATED_PLUGIN_REGISTRANT_ 8 | #define GENERATED_PLUGIN_REGISTRANT_ 9 | 10 | #include 11 | 12 | // Registers Flutter plugins. 13 | void fl_register_plugins(FlPluginRegistry* registry); 14 | 15 | #endif // GENERATED_PLUGIN_REGISTRANT_ 16 | -------------------------------------------------------------------------------- /windows/flutter/generated_plugin_registrant.h: -------------------------------------------------------------------------------- 1 | // 2 | // Generated file. Do not edit. 3 | // 4 | 5 | // clang-format off 6 | 7 | #ifndef GENERATED_PLUGIN_REGISTRANT_ 8 | #define GENERATED_PLUGIN_REGISTRANT_ 9 | 10 | #include 11 | 12 | // Registers Flutter plugins. 13 | void RegisterPlugins(flutter::PluginRegistry* registry); 14 | 15 | #endif // GENERATED_PLUGIN_REGISTRANT_ 16 | -------------------------------------------------------------------------------- /.analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:effective_dart/analysis_options.1.2.0.yaml 2 | linter: 3 | rules: 4 | public_member_api_docs: false 5 | lines_longer_than_80_chars: false 6 | type_annotate_public_apis: false 7 | avoid_catches_without_on_clauses: false 8 | avoid_setters_without_getters: false 9 | avoid_equals_and_hash_code_on_mutable_classes: false 10 | avoid_returning_null: false 11 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "tsacdop_desktop", 9 | "request": "launch", 10 | "type": "dart" 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /.vscode/c_cpp_properties.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "name": "Linux", 5 | "includePath": [ 6 | "${workspaceFolder}/**" 7 | ], 8 | "defines": [], 9 | "compilerPath": "/usr/bin/clang", 10 | "cStandard": "c17", 11 | "cppStandard": "c++14", 12 | "intelliSenseMode": "linux-clang-x64" 13 | } 14 | ], 15 | "version": 4 16 | } -------------------------------------------------------------------------------- /linux/my_application.h: -------------------------------------------------------------------------------- 1 | #ifndef FLUTTER_MY_APPLICATION_H_ 2 | #define FLUTTER_MY_APPLICATION_H_ 3 | 4 | #include 5 | 6 | G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, 7 | GtkApplication) 8 | 9 | /** 10 | * my_application_new: 11 | * 12 | * Creates a new Flutter-based application. 13 | * 14 | * Returns: a new #MyApplication. 15 | */ 16 | MyApplication* my_application_new(); 17 | 18 | #endif // FLUTTER_MY_APPLICATION_H_ 19 | -------------------------------------------------------------------------------- /windows/runner/resource.h: -------------------------------------------------------------------------------- 1 | //{{NO_DEPENDENCIES}} 2 | // Microsoft Visual C++ generated include file. 3 | // Used by Runner.rc 4 | // 5 | #define IDI_APP_ICON 101 6 | 7 | // Next default values for new objects 8 | // 9 | #ifdef APSTUDIO_INVOKED 10 | #ifndef APSTUDIO_READONLY_SYMBOLS 11 | #define _APS_NEXT_RESOURCE_VALUE 102 12 | #define _APS_NEXT_COMMAND_VALUE 40001 13 | #define _APS_NEXT_CONTROL_VALUE 1001 14 | #define _APS_NEXT_SYMED_VALUE 101 15 | #endif 16 | #endif 17 | -------------------------------------------------------------------------------- /windows/runner/utils.cpp: -------------------------------------------------------------------------------- 1 | #include "utils.h" 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | 8 | #include 9 | 10 | void CreateAndAttachConsole() { 11 | if (::AllocConsole()) { 12 | FILE *unused; 13 | if (freopen_s(&unused, "CONOUT$", "w", stdout)) { 14 | _dup2(_fileno(stdout), 1); 15 | } 16 | if (freopen_s(&unused, "CONOUT$", "w", stderr)) { 17 | _dup2(_fileno(stdout), 2); 18 | } 19 | std::ios::sync_with_stdio(); 20 | FlutterDesktopResyncOutputStreams(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /linux/flutter/generated_plugins.cmake: -------------------------------------------------------------------------------- 1 | # 2 | # Generated file, do not edit. 3 | # 4 | 5 | list(APPEND FLUTTER_PLUGIN_LIST 6 | dart_vlc 7 | url_launcher_linux 8 | window_manager 9 | ) 10 | 11 | set(PLUGIN_BUNDLED_LIBRARIES) 12 | 13 | foreach(plugin ${FLUTTER_PLUGIN_LIST}) 14 | add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) 15 | target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) 16 | list(APPEND PLUGIN_BUNDLED_LIBRARIES $) 17 | list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) 18 | endforeach(plugin) 19 | -------------------------------------------------------------------------------- /windows/flutter/generated_plugins.cmake: -------------------------------------------------------------------------------- 1 | # 2 | # Generated file, do not edit. 3 | # 4 | 5 | list(APPEND FLUTTER_PLUGIN_LIST 6 | dart_vlc 7 | flutter_native_view 8 | url_launcher_windows 9 | window_manager 10 | ) 11 | 12 | set(PLUGIN_BUNDLED_LIBRARIES) 13 | 14 | foreach(plugin ${FLUTTER_PLUGIN_LIST}) 15 | add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) 16 | target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) 17 | list(APPEND PLUGIN_BUNDLED_LIBRARIES $) 18 | list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) 19 | endforeach(plugin) 20 | -------------------------------------------------------------------------------- /windows/runner/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required(VERSION 3.15) 2 | project(runner LANGUAGES CXX) 3 | 4 | add_executable(${BINARY_NAME} WIN32 5 | "flutter_window.cpp" 6 | "main.cpp" 7 | "run_loop.cpp" 8 | "utils.cpp" 9 | "win32_window.cpp" 10 | "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" 11 | "Runner.rc" 12 | "runner.exe.manifest" 13 | ) 14 | apply_standard_settings(${BINARY_NAME}) 15 | target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") 16 | target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) 17 | target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") 18 | add_dependencies(${BINARY_NAME} flutter_assemble) 19 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4buy-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: ["https://www.buymeacoffee.com/stonegate"] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /windows/flutter/generated_plugin_registrant.cc: -------------------------------------------------------------------------------- 1 | // 2 | // Generated file. Do not edit. 3 | // 4 | 5 | // clang-format off 6 | 7 | #include "generated_plugin_registrant.h" 8 | 9 | #include 10 | #include 11 | #include 12 | #include 13 | 14 | void RegisterPlugins(flutter::PluginRegistry* registry) { 15 | DartVlcPluginRegisterWithRegistrar( 16 | registry->GetRegistrarForPlugin("DartVlcPlugin")); 17 | FlutterNativeViewPluginRegisterWithRegistrar( 18 | registry->GetRegistrarForPlugin("FlutterNativeViewPlugin")); 19 | UrlLauncherWindowsRegisterWithRegistrar( 20 | registry->GetRegistrarForPlugin("UrlLauncherWindows")); 21 | WindowManagerPluginRegisterWithRegistrar( 22 | registry->GetRegistrarForPlugin("WindowManagerPlugin")); 23 | } 24 | -------------------------------------------------------------------------------- /windows/runner/runner.exe.manifest: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PerMonitorV2 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /linux/flutter/generated_plugin_registrant.cc: -------------------------------------------------------------------------------- 1 | // 2 | // Generated file. Do not edit. 3 | // 4 | 5 | // clang-format off 6 | 7 | #include "generated_plugin_registrant.h" 8 | 9 | #include 10 | #include 11 | #include 12 | 13 | void fl_register_plugins(FlPluginRegistry* registry) { 14 | g_autoptr(FlPluginRegistrar) dart_vlc_registrar = 15 | fl_plugin_registry_get_registrar_for_plugin(registry, "DartVlcPlugin"); 16 | dart_vlc_plugin_register_with_registrar(dart_vlc_registrar); 17 | g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = 18 | fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); 19 | url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); 20 | g_autoptr(FlPluginRegistrar) window_manager_registrar = 21 | fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin"); 22 | window_manager_plugin_register_with_registrar(window_manager_registrar); 23 | } 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | .fvm/ 12 | 13 | # IntelliJ related 14 | *.iml 15 | *.ipr 16 | *.iws 17 | .idea/ 18 | 19 | # The .vscode folder contains launch configuration and tasks you configure in 20 | # VS Code which you may wish to be included in version control, so this line 21 | # is commented out by default. 22 | #.vscode/ 23 | 24 | # Flutter/Dart/Pub related 25 | **/doc/api/ 26 | **/ios/Flutter/.last_build_id 27 | .dart_tool/ 28 | .flutter-plugins 29 | .flutter-plugins-dependencies 30 | .packages 31 | .pub-cache/ 32 | .pub/ 33 | /build/ 34 | 35 | # Web related 36 | lib/generated_plugin_registrant.dart 37 | 38 | # Linux 39 | linux/*.deb 40 | linux/*.rpm 41 | linux/debian/usr/bin 42 | 43 | # Symbolication related 44 | app.*.symbols 45 | 46 | # Obfuscation related 47 | app.*.map.json 48 | 49 | # Android Studio will place build artifacts here 50 | /android/app/debug 51 | /android/app/profile 52 | /android/app/release 53 | 54 | .env.dart 55 | 56 | images/ 57 | podcasts.db 58 | podcasts.db-journal -------------------------------------------------------------------------------- /test/widget_test.dart: -------------------------------------------------------------------------------- 1 | // This is a basic Flutter widget test. 2 | // 3 | // To perform an interaction with a widget in your test, use the WidgetTester 4 | // utility that Flutter provides. For example, you can send tap and scroll 5 | // gestures. You can also use WidgetTester to find child widgets in the widget 6 | // tree, read text, and verify that the values of widget properties are correct. 7 | 8 | import 'package:flutter/material.dart'; 9 | import 'package:flutter_test/flutter_test.dart'; 10 | 11 | import 'package:tsacdop_desktop/main.dart'; 12 | 13 | void main() { 14 | testWidgets('Counter increments smoke test', (WidgetTester tester) async { 15 | // Build our app and trigger a frame. 16 | await tester.pumpWidget(MyApp()); 17 | 18 | // Verify that our counter starts at 0. 19 | expect(find.text('0'), findsOneWidget); 20 | expect(find.text('1'), findsNothing); 21 | 22 | // Tap the '+' icon and trigger a frame. 23 | await tester.tap(find.byIcon(Icons.add)); 24 | await tester.pump(); 25 | 26 | // Verify that our counter has incremented. 27 | expect(find.text('0'), findsNothing); 28 | expect(find.text('1'), findsOneWidget); 29 | }); 30 | } 31 | -------------------------------------------------------------------------------- /windows/runner/run_loop.h: -------------------------------------------------------------------------------- 1 | #ifndef RUNNER_RUN_LOOP_H_ 2 | #define RUNNER_RUN_LOOP_H_ 3 | 4 | #include 5 | 6 | #include 7 | #include 8 | 9 | // A runloop that will service events for Flutter instances as well 10 | // as native messages. 11 | class RunLoop { 12 | public: 13 | RunLoop(); 14 | ~RunLoop(); 15 | 16 | // Prevent copying 17 | RunLoop(RunLoop const&) = delete; 18 | RunLoop& operator=(RunLoop const&) = delete; 19 | 20 | // Runs the run loop until the application quits. 21 | void Run(); 22 | 23 | // Registers the given Flutter instance for event servicing. 24 | void RegisterFlutterInstance( 25 | flutter::FlutterEngine* flutter_instance); 26 | 27 | // Unregisters the given Flutter instance from event servicing. 28 | void UnregisterFlutterInstance( 29 | flutter::FlutterEngine* flutter_instance); 30 | 31 | private: 32 | using TimePoint = std::chrono::steady_clock::time_point; 33 | 34 | // Processes all currently pending messages for registered Flutter instances. 35 | TimePoint ProcessFlutterMessages(); 36 | 37 | std::set flutter_instances_; 38 | }; 39 | 40 | #endif // RUNNER_RUN_LOOP_H_ 41 | -------------------------------------------------------------------------------- /windows/runner/main.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | 5 | #include "flutter_window.h" 6 | #include "run_loop.h" 7 | #include "utils.h" 8 | 9 | int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, 10 | _In_ wchar_t *command_line, _In_ int show_command) 11 | { 12 | // Attach to console when present (e.g., 'flutter run') or create a 13 | // new console when running with a debugger. 14 | if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) 15 | { 16 | CreateAndAttachConsole(); 17 | } 18 | 19 | // Initialize COM, so that it is available for use in the library and/or 20 | // plugins. 21 | ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); 22 | 23 | RunLoop run_loop; 24 | 25 | flutter::DartProject project(L"data"); 26 | FlutterWindow window(&run_loop, project); 27 | Win32Window::Point origin(10, 10); 28 | Win32Window::Size size(1280, 720); 29 | if (!window.CreateAndShow(L"Tsacdop Desktop", origin, size)) 30 | { 31 | return EXIT_FAILURE; 32 | } 33 | window.SetQuitOnClose(true); 34 | 35 | run_loop.Run(); 36 | 37 | ::CoUninitialize(); 38 | return EXIT_SUCCESS; 39 | } 40 | -------------------------------------------------------------------------------- /windows/runner/flutter_window.h: -------------------------------------------------------------------------------- 1 | #ifndef RUNNER_FLUTTER_WINDOW_H_ 2 | #define RUNNER_FLUTTER_WINDOW_H_ 3 | 4 | #include 5 | #include 6 | 7 | #include 8 | 9 | #include "run_loop.h" 10 | #include "win32_window.h" 11 | 12 | // A window that does nothing but host a Flutter view. 13 | class FlutterWindow : public Win32Window { 14 | public: 15 | // Creates a new FlutterWindow driven by the |run_loop|, hosting a 16 | // Flutter view running |project|. 17 | explicit FlutterWindow(RunLoop* run_loop, 18 | const flutter::DartProject& project); 19 | virtual ~FlutterWindow(); 20 | 21 | protected: 22 | // Win32Window: 23 | bool OnCreate() override; 24 | void OnDestroy() override; 25 | LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, 26 | LPARAM const lparam) noexcept override; 27 | 28 | private: 29 | // The run loop driving events for this window. 30 | RunLoop* run_loop_; 31 | 32 | // The project to run. 33 | flutter::DartProject project_; 34 | 35 | // The Flutter instance hosted by this window. 36 | std::unique_ptr flutter_controller_; 37 | }; 38 | 39 | #endif // RUNNER_FLUTTER_WINDOW_H_ 40 | -------------------------------------------------------------------------------- /lib/models/service_api/searchepisodes.dart: -------------------------------------------------------------------------------- 1 | import 'package:json_annotation/json_annotation.dart'; 2 | part 'searchepisodes.g.dart'; 3 | 4 | @JsonSerializable() 5 | class SearchEpisodes { 6 | @_ConvertE() 7 | final List? episodes; 8 | @JsonKey(name: 'next_episode_pub_date') 9 | final int? nextEpisodeDate; 10 | SearchEpisodes({this.episodes, this.nextEpisodeDate}); 11 | factory SearchEpisodes.fromJson(Map json) => 12 | _$SearchEpisodesFromJson(json); 13 | Map toJson() => _$SearchEpisodesToJson(this); 14 | } 15 | 16 | class _ConvertE implements JsonConverter { 17 | const _ConvertE(); 18 | @override 19 | E fromJson(Object? json) { 20 | return OnlineEpisode.fromJson(json as Map) as E; 21 | } 22 | 23 | @override 24 | Object? toJson(E object) { 25 | return object; 26 | } 27 | } 28 | 29 | @JsonSerializable() 30 | class OnlineEpisode { 31 | final String? title; 32 | @JsonKey(name: 'pub_date_ms') 33 | final int? pubDate; 34 | @JsonKey(name: 'audio_length_sec') 35 | final int? length; 36 | OnlineEpisode({this.title, this.pubDate, this.length}); 37 | factory OnlineEpisode.fromJson(Map json) => 38 | _$OnlineEpisodeFromJson(json); 39 | Map toJson() => _$OnlineEpisodeToJson(this); 40 | } 41 | -------------------------------------------------------------------------------- /.github/workflows/linux_build.yml: -------------------------------------------------------------------------------- 1 | name: Build tsacdop desktop 2 | on: 3 | push: 4 | branches: 5 | - master 6 | workflow_dispatch: 7 | 8 | jobs: 9 | build_linux: 10 | name: Tsacdop Linux 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - uses: subosito/flutter-action@v1 15 | with: 16 | channel: "beta" 17 | - run: sudo apt-get update 18 | - run: sudo apt-get install clang cmake ninja-build pkg-config libgtk-3-dev libwebkit2gtk-4.0-dev libmediainfo-dev dpkg-dev alien vlc libvlc-dev 19 | - run: flutter config --enable-linux-desktop 20 | - run: flutter pub get 21 | - run: flutter build linux --release --verbose 22 | 23 | - name: Build Tarball 24 | run: | 25 | cp -fr build/linux/x64/release/bundle linux/flatpak/ 26 | tar czf tsacdop-linux-x86_64.tar.gz -C linux/flatpak/ . 27 | 28 | - uses: actions/upload-artifact@v2 29 | with: 30 | name: tsacdop-release 31 | path: tsacdop-linux-x86_64.tar.gz 32 | # - name: Release 33 | # uses: softprops/action-gh-release@v1 34 | # with: 35 | # draft: true 36 | # prerelease: false 37 | # body: "" 38 | # tag_name: "vnext" 39 | # files: tsacdop-linux-* 40 | # token: ${{ secrets.GITHUB_TOKEN }} 41 | -------------------------------------------------------------------------------- /lib/models/service_api/searchepisodes.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'searchepisodes.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | SearchEpisodes _$SearchEpisodesFromJson(Map json) => 10 | SearchEpisodes( 11 | episodes: (json['episodes'] as List?) 12 | ?.map(_ConvertE().fromJson) 13 | .toList(), 14 | nextEpisodeDate: json['next_episode_pub_date'] as int?, 15 | ); 16 | 17 | Map _$SearchEpisodesToJson(SearchEpisodes instance) => 18 | { 19 | 'episodes': instance.episodes?.map(_ConvertE().toJson).toList(), 20 | 'next_episode_pub_date': instance.nextEpisodeDate, 21 | }; 22 | 23 | OnlineEpisode _$OnlineEpisodeFromJson(Map json) => 24 | OnlineEpisode( 25 | title: json['title'] as String?, 26 | pubDate: json['pub_date_ms'] as int?, 27 | length: json['audio_length_sec'] as int?, 28 | ); 29 | 30 | Map _$OnlineEpisodeToJson(OnlineEpisode instance) => 31 | { 32 | 'title': instance.title, 33 | 'pub_date_ms': instance.pubDate, 34 | 'audio_length_sec': instance.length, 35 | }; 36 | -------------------------------------------------------------------------------- /lib/models/podcastlocal.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:equatable/equatable.dart'; 4 | import 'package:flutter/material.dart'; 5 | 6 | import '../utils/extension_helper.dart'; 7 | 8 | class PodcastLocal extends Equatable { 9 | final String? title; 10 | final String? imageUrl; 11 | final String rssUrl; 12 | final String? author; 13 | final String? primaryColor; 14 | final String? id; 15 | final String? imagePath; 16 | final String? provider; 17 | final String? link; 18 | final String? description; 19 | final int upateCount; 20 | final int episodeCount; 21 | 22 | PodcastLocal(this.title, this.imageUrl, this.rssUrl, this.primaryColor, 23 | this.author, this.id, this.imagePath, this.provider, this.link, 24 | {this.description = '', int? upateCount, int? episodeCount}) 25 | : episodeCount = episodeCount ?? 0, 26 | upateCount = upateCount ?? 0; 27 | 28 | ImageProvider get avatarImage { 29 | return (File(imagePath!).existsSync() 30 | ? FileImage(File(imagePath!)) 31 | : const AssetImage('assets/avatar_backup.png')) 32 | as ImageProvider; 33 | } 34 | 35 | Color backgroudColor(BuildContext context) { 36 | return context.brightness == Brightness.light 37 | ? primaryColor!.colorizedark() 38 | : primaryColor!.colorizeLight(); 39 | } 40 | 41 | @override 42 | List get props => [id, rssUrl]; 43 | } 44 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | # This file configures the analyzer, which statically analyzes Dart code to 2 | # check for errors, warnings, and lints. 3 | # 4 | # The issues identified by the analyzer are surfaced in the UI of Dart-enabled 5 | # IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be 6 | # invoked from the command line by running `flutter analyze`. 7 | 8 | # The following line activates a set of recommended lints for Flutter apps, 9 | # packages, and plugins designed to encourage good coding practices. 10 | include: package:flutter_lints/flutter.yaml 11 | 12 | linter: 13 | # The lint rules applied to this project can be customized in the 14 | # section below to disable rules from the `package:flutter_lints/flutter.yaml` 15 | # included above or to enable additional rules. A list of all available lints 16 | # and their documentation is published at 17 | # https://dart-lang.github.io/linter/lints/index.html. 18 | # 19 | # Instead of disabling a lint rule for the entire project in the 20 | # section below, it can also be suppressed for a single line of code 21 | # or a specific dart file by using the `// ignore: name_of_lint` and 22 | # `// ignore_for_file: name_of_lint` syntax on the line or in the file 23 | # producing the lint. 24 | rules: 25 | # avoid_print: false # Uncomment to disable the `avoid_print` rule 26 | # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule 27 | 28 | # Additional information about this file can be found at 29 | # https://dart.dev/guides/language/analysis-options 30 | -------------------------------------------------------------------------------- /lib/main.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:dart_vlc/dart_vlc.dart'; 4 | import 'package:flutter/material.dart'; 5 | import 'package:flutter_localizations/flutter_localizations.dart'; 6 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 7 | 8 | import 'generated/l10n.dart'; 9 | import 'screens/home.dart'; 10 | import 'providers/settings_state.dart'; 11 | 12 | void main() async { 13 | await settingsState.initTheme(); 14 | 15 | if (settingsState.proxy != '') { 16 | HttpOverrides.global = _HttpOverrides(settingsState.proxy); 17 | } 18 | runApp(ProviderScope(child: MyApp())); 19 | DartVLC.initialize(); 20 | } 21 | 22 | class _HttpOverrides extends HttpOverrides { 23 | final String? proxy; 24 | _HttpOverrides(this.proxy); 25 | @override 26 | String findProxyFromEnvironment(_, __) { 27 | return 'PROXY $proxy;'; 28 | } 29 | } 30 | 31 | class MyApp extends ConsumerWidget { 32 | @override 33 | Widget build(BuildContext contextm, WidgetRef ref) { 34 | var theme = ref.watch(settings); 35 | return MaterialApp( 36 | debugShowCheckedModeBanner: false, 37 | title: 'Tsacdop Desktop', 38 | theme: theme.lightTheme, 39 | darkTheme: theme.darkTheme, 40 | themeMode: theme.themeMode, 41 | localizationsDelegates: [ 42 | S.delegate, 43 | GlobalMaterialLocalizations.delegate, 44 | GlobalWidgetsLocalizations.delegate, 45 | GlobalCupertinoLocalizations.delegate, 46 | ], 47 | supportedLocales: S.delegate.supportedLocales, 48 | home: Home(), 49 | ); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /linux/flatpak/me.stonegate.tsacdop.metainfo.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | me.stonegate.tsacdop 4 | Tsacdop 5 | Enjoy podcast with Tsacdop 6 | CC0-1.0 7 | GPL-3.0-only 8 | 9 | pointing 10 | keyboard 11 | touch 12 | 13 | stone 14 | https://tsacdop.app 15 | 16 |

Enjoy podcasts with Tsacdop! Tsacdop is a podcast player developed with Flutter, a clean, simply beautiful and friendly app, which is also free and open source.

17 |

Features

18 |
    19 |
  • Podcast group manage
  • 20 |
  • Podcast playlist
  • 21 |
  • Podcast search via podcastindex
  • 22 |
  • Open source and free
  • 23 |
24 |
25 | 26 | 27 | 28 | https://raw.githubusercontent.com/tsacdop/tsacdop_desktop/master/preview/linux_home.png 29 | 30 | 31 | me.stonegate.tsacdop.desktop 32 | 33 | 34 | 35 | 36 | 37 | tsacdop.app@gmail.com 38 |
-------------------------------------------------------------------------------- /lib/models/service_api/index_episode.dart: -------------------------------------------------------------------------------- 1 | import 'package:json_annotation/json_annotation.dart'; 2 | import 'searchepisodes.dart'; 3 | 4 | part 'index_episode.g.dart'; 5 | 6 | @JsonSerializable() 7 | class IndexEpisodeResult

{ 8 | @_ConvertP() 9 | final List

? items; 10 | final String? status; 11 | final int? count; 12 | IndexEpisodeResult({this.items, this.status, this.count}); 13 | factory IndexEpisodeResult.fromJson(Map json) => 14 | _$IndexEpisodeResultFromJson

(json); 15 | Map toJson() => _$IndexEpisodeResultToJson(this); 16 | } 17 | 18 | class _ConvertP

implements JsonConverter { 19 | const _ConvertP(); 20 | @override 21 | P fromJson(Object? json) { 22 | return IndexEpisode.fromJson(json as Map) as P; 23 | } 24 | 25 | @override 26 | Object? toJson(P object) { 27 | return object; 28 | } 29 | } 30 | 31 | @JsonSerializable() 32 | class IndexEpisode { 33 | final String? title; 34 | final String? description; 35 | final int? datePublished; 36 | final String? enclosureUrl; 37 | final int? enclosureLength; 38 | IndexEpisode( 39 | {this.title, 40 | this.description, 41 | this.datePublished, 42 | this.enclosureLength, 43 | this.enclosureUrl}); 44 | 45 | factory IndexEpisode.fromJson(Map json) => 46 | _$IndexEpisodeFromJson(json); 47 | Map toJson() => _$IndexEpisodeToJson(this); 48 | 49 | OnlineEpisode get toOnlineWEpisode => 50 | OnlineEpisode(title: title, pubDate: datePublished! * 1000, length: 0); 51 | } 52 | -------------------------------------------------------------------------------- /lib/models/service_api/index_episode.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'index_episode.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | IndexEpisodeResult

_$IndexEpisodeResultFromJson

( 10 | Map json) => 11 | IndexEpisodeResult

( 12 | items: (json['items'] as List?) 13 | ?.map(_ConvertP

().fromJson) 14 | .toList(), 15 | status: json['status'] as String?, 16 | count: json['count'] as int?, 17 | ); 18 | 19 | Map _$IndexEpisodeResultToJson

( 20 | IndexEpisodeResult

instance) => 21 | { 22 | 'items': instance.items?.map(_ConvertP

().toJson).toList(), 23 | 'status': instance.status, 24 | 'count': instance.count, 25 | }; 26 | 27 | IndexEpisode _$IndexEpisodeFromJson(Map json) => IndexEpisode( 28 | title: json['title'] as String?, 29 | description: json['description'] as String?, 30 | datePublished: json['datePublished'] as int?, 31 | enclosureLength: json['enclosureLength'] as int?, 32 | enclosureUrl: json['enclosureUrl'] as String?, 33 | ); 34 | 35 | Map _$IndexEpisodeToJson(IndexEpisode instance) => 36 | { 37 | 'title': instance.title, 38 | 'description': instance.description, 39 | 'datePublished': instance.datePublished, 40 | 'enclosureUrl': instance.enclosureUrl, 41 | 'enclosureLength': instance.enclosureLength, 42 | }; 43 | -------------------------------------------------------------------------------- /lib/models/service_api/itunes_podcast.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'itunes_podcast.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | ItunesSearchResult

_$ItunesSearchResultFromJson

( 10 | Map json) => 11 | ItunesSearchResult

( 12 | resultCount: json['resultCount'] as int?, 13 | results: (json['results'] as List?) 14 | ?.map(_ConvertP

().fromJson) 15 | .toList(), 16 | ); 17 | 18 | Map _$ItunesSearchResultToJson

( 19 | ItunesSearchResult

instance) => 20 | { 21 | 'results': instance.results?.map(_ConvertP

().toJson).toList(), 22 | 'resultCount': instance.resultCount, 23 | }; 24 | 25 | ItunesPodcast _$ItunesPodcastFromJson(Map json) => 26 | ItunesPodcast( 27 | artistName: json['artistName'] as String?, 28 | collectionName: json['collectionName'] as String?, 29 | feedUrl: json['feedUrl'] as String?, 30 | artworkUrl600: json['artworkUrl600'] as String?, 31 | releaseDate: json['releaseDate'] as String?, 32 | collectionId: json['collectionId'] as int?, 33 | ); 34 | 35 | Map _$ItunesPodcastToJson(ItunesPodcast instance) => 36 | { 37 | 'artistName': instance.artistName, 38 | 'collectionName': instance.collectionName, 39 | 'feedUrl': instance.feedUrl, 40 | 'artworkUrl600': instance.artworkUrl600, 41 | 'releaseDate': instance.releaseDate, 42 | 'collectionId': instance.collectionId, 43 | }; 44 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: tsacdop_desktop 2 | description: Enjoy podcasts with Tsacdop! 3 | publish_to: none 4 | 5 | version: 0.1.1+3 6 | 7 | environment: 8 | sdk: '>=2.12.0 <3.0.0' 9 | 10 | dependencies: 11 | flutter: 12 | sdk: flutter 13 | flutter_localizations: 14 | sdk: flutter 15 | cached_network_image: ^3.1.0 16 | color_thief_dart: ^0.1.0 17 | cupertino_icons: ^1.0.2 18 | crypto: ^3.0.1 19 | convert: ^3.0.0 20 | equatable: ^2.0.0 21 | effective_dart: ^1.3.1 22 | flutter_riverpod: ^1.0.0 23 | flutter_linkify: ^5.0.2 24 | dio: ^4.0.0 25 | google_fonts: ^2.0.0 26 | flutter_html: ^2.1.5 27 | image: ^3.0.1 28 | path_provider: ^2.0.1 29 | http_proxy: 30 | git: 31 | url: https://github.com/Tonigt/http_proxy.git 32 | shared_preferences: ^2.0.5 33 | sqflite_common_ffi: ^2.0.0 34 | url_launcher: ^6.0.3 35 | uuid: ^3.0.4 36 | desktop_notifications: ^0.6.1 37 | line_icons: ^2.0.1 38 | webfeed: 39 | git: 40 | url: https://github.com/stonega/webfeed.git 41 | resizable_widget: 42 | git: 43 | url: https://github.com/tsacdop/resizable_widget.git 44 | json_annotation: ^4.3.0 45 | dart_vlc: 46 | git: 47 | url: https://github.com/alexmercerind/dart_vlc.git 48 | ref: master 49 | platform: ^3.1.0 50 | 51 | dependency_overrides: 52 | xml: 5.0.0 53 | http: 0.13.0 54 | dart_vlc_ffi: 55 | git: 56 | url: https://github.com/alexmercerind/dart_vlc.git 57 | ref: master 58 | path: ffi 59 | linkify: 60 | git: 61 | url: https://github.com/stonega/linkify.git 62 | 63 | dev_dependencies: 64 | flutter_test: 65 | sdk: flutter 66 | build_runner: ^2.1.5 67 | json_serializable: ^6.0.1 68 | 69 | flutter: 70 | assets: 71 | - assets/ 72 | uses-material-design: true 73 | 74 | flutter_intl: 75 | enabled: true 76 | localizely: 77 | project_id: bde4e9bd-4cb2-449b-9de2-18f231ddb47d 78 | -------------------------------------------------------------------------------- /lib/widgets/custom_button.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | import '../utils/extension_helper.dart'; 4 | 5 | class CustomIconButton extends StatelessWidget { 6 | final Widget? icon; 7 | final Size size; 8 | final Function()? onPressed; 9 | final bool pressed; 10 | CustomIconButton( 11 | {this.icon, this.onPressed, Size? size, required this.pressed, Key? key}) 12 | : assert(pressed != null), 13 | this.size = size ?? Size(50, 50), 14 | super(key: key); 15 | 16 | @override 17 | Widget build(BuildContext context) { 18 | return Material( 19 | color: Colors.transparent, 20 | child: Stack( 21 | children: [ 22 | ClipRRect( 23 | borderRadius: BorderRadius.only( 24 | topRight: Radius.circular(4), bottomRight: Radius.circular(4)), 25 | child: InkWell( 26 | onTap: onPressed, 27 | child: SizedBox( 28 | height: size.height, 29 | width: size.width, 30 | child: IconTheme( 31 | data: IconThemeData( 32 | color: pressed 33 | ? context.accentColor 34 | : context.textColor), 35 | child: icon!), 36 | ), 37 | ), 38 | ), 39 | if (pressed) 40 | Positioned( 41 | left: 0, 42 | child: Container( 43 | width: size.width / 10, 44 | height: size.height, 45 | decoration: BoxDecoration( 46 | borderRadius: BorderRadius.only( 47 | topRight: Radius.circular(4), 48 | bottomRight: Radius.circular(4)), 49 | color: context.accentColor), 50 | ), 51 | ) 52 | ], 53 | ), 54 | ); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /lib/models/service_api/index_podcast.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'index_podcast.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | PodcastIndexSearchResult

_$PodcastIndexSearchResultFromJson

( 10 | Map json) => 11 | PodcastIndexSearchResult

( 12 | feeds: (json['feeds'] as List?) 13 | ?.map(_ConvertP

().fromJson) 14 | .toList(), 15 | status: json['status'] as String?, 16 | count: json['count'] as int?, 17 | ); 18 | 19 | Map _$PodcastIndexSearchResultToJson

( 20 | PodcastIndexSearchResult

instance) => 21 | { 22 | 'feeds': instance.feeds?.map(_ConvertP

().toJson).toList(), 23 | 'status': instance.status, 24 | 'count': instance.count, 25 | }; 26 | 27 | IndexPodcast _$IndexPodcastFromJson(Map json) => IndexPodcast( 28 | id: json['id'] as int?, 29 | title: json['title'] as String?, 30 | url: json['url'] as String?, 31 | link: json['link'] as String?, 32 | description: json['description'] as String?, 33 | author: json['author'] as String?, 34 | image: json['image'] as String?, 35 | lastUpdateTime: json['lastUpdateTime'] as int?, 36 | itunesId: json['itunesId'] as int?, 37 | ); 38 | 39 | Map _$IndexPodcastToJson(IndexPodcast instance) => 40 | { 41 | 'id': instance.id, 42 | 'title': instance.title, 43 | 'url': instance.url, 44 | 'link': instance.link, 45 | 'description': instance.description, 46 | 'author': instance.author, 47 | 'image': instance.image, 48 | 'lastUpdateTime': instance.lastUpdateTime, 49 | 'itunesId': instance.itunesId, 50 | }; 51 | -------------------------------------------------------------------------------- /lib/models/service_api/index_podcast.dart: -------------------------------------------------------------------------------- 1 | import 'package:json_annotation/json_annotation.dart'; 2 | 3 | import 'searchpodcast.dart'; 4 | 5 | part 'index_podcast.g.dart'; 6 | 7 | @JsonSerializable() 8 | class PodcastIndexSearchResult

{ 9 | @_ConvertP() 10 | final List

? feeds; 11 | final String? status; 12 | final int? count; 13 | PodcastIndexSearchResult({this.feeds, this.status, this.count}); 14 | 15 | factory PodcastIndexSearchResult.fromJson(Map json) => 16 | _$PodcastIndexSearchResultFromJson

(json); 17 | Map toJson() => _$PodcastIndexSearchResultToJson(this); 18 | } 19 | 20 | class _ConvertP

implements JsonConverter { 21 | const _ConvertP(); 22 | @override 23 | P fromJson(Object? json) { 24 | return IndexPodcast.fromJson(json as Map) as P; 25 | } 26 | 27 | @override 28 | Object? toJson(P object) { 29 | return object; 30 | } 31 | } 32 | 33 | @JsonSerializable() 34 | class IndexPodcast { 35 | final int? id; 36 | final String? title; 37 | final String? url; 38 | final String? link; 39 | final String? description; 40 | final String? author; 41 | final String? image; 42 | final int? lastUpdateTime; 43 | final int? itunesId; 44 | IndexPodcast( 45 | {this.id, 46 | this.title, 47 | this.url, 48 | this.link, 49 | this.description, 50 | this.author, 51 | this.image, 52 | this.lastUpdateTime, 53 | this.itunesId}); 54 | factory IndexPodcast.fromJson(Map json) => 55 | _$IndexPodcastFromJson(json); 56 | Map toJson() => _$IndexPodcastToJson(this); 57 | 58 | OnlinePodcast get toOnlinePodcast => OnlinePodcast( 59 | earliestPubDate: 0, 60 | title: title, 61 | count: 0, 62 | description: description, 63 | image: image, 64 | latestPubDate: lastUpdateTime! * 1000, 65 | rss: url, 66 | publisher: author, 67 | id: itunesId.toString()); 68 | } 69 | -------------------------------------------------------------------------------- /lib/models/service_api/itunes_podcast.dart: -------------------------------------------------------------------------------- 1 | import 'package:intl/intl.dart'; 2 | import 'package:json_annotation/json_annotation.dart'; 3 | 4 | import 'searchpodcast.dart'; 5 | 6 | part 'itunes_podcast.g.dart'; 7 | 8 | @JsonSerializable() 9 | class ItunesSearchResult

{ 10 | @_ConvertP() 11 | final List

? results; 12 | final int? resultCount; 13 | ItunesSearchResult({this.resultCount, this.results}); 14 | 15 | factory ItunesSearchResult.fromJson(Map json) => 16 | _$ItunesSearchResultFromJson

(json); 17 | Map toJson() => _$ItunesSearchResultToJson(this); 18 | } 19 | 20 | class _ConvertP

implements JsonConverter { 21 | const _ConvertP(); 22 | @override 23 | P fromJson(Object? json) { 24 | return ItunesPodcast.fromJson(json as Map) as P; 25 | } 26 | 27 | @override 28 | Object? toJson(P object) { 29 | return object; 30 | } 31 | } 32 | 33 | @JsonSerializable() 34 | class ItunesPodcast { 35 | final String? artistName; 36 | final String? collectionName; 37 | final String? feedUrl; 38 | final String? artworkUrl600; 39 | final String? releaseDate; 40 | final int? collectionId; 41 | 42 | ItunesPodcast( 43 | {this.artistName, 44 | this.collectionName, 45 | this.feedUrl, 46 | this.artworkUrl600, 47 | this.releaseDate, 48 | this.collectionId}); 49 | 50 | factory ItunesPodcast.fromJson(Map json) => 51 | _$ItunesPodcastFromJson(json); 52 | Map toJson() => _$ItunesPodcastToJson(this); 53 | 54 | int get latestPubDate => DateFormat('yyyy-MM-DDTHH:MM:SSZ', 'en_US') 55 | .parse(releaseDate!) 56 | .millisecondsSinceEpoch; 57 | OnlinePodcast get toOnlinePodcast => OnlinePodcast( 58 | earliestPubDate: 0, 59 | title: collectionName, 60 | count: 0, 61 | description: '', 62 | image: artworkUrl600, 63 | latestPubDate: latestPubDate, 64 | rss: feedUrl, 65 | publisher: artistName, 66 | id: collectionId.toString()); 67 | } 68 | -------------------------------------------------------------------------------- /lib/models/service_api/searchpodcast.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'searchpodcast.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | SearchPodcast

_$SearchPodcastFromJson

(Map json) => 10 | SearchPodcast

( 11 | results: (json['results'] as List?) 12 | ?.map(_ConvertP

().fromJson) 13 | .toList(), 14 | nextOffset: json['next_offset'] as int?, 15 | total: json['total'] as int?, 16 | count: json['count'] as int?, 17 | ); 18 | 19 | Map _$SearchPodcastToJson

(SearchPodcast

instance) => 20 | { 21 | 'results': instance.results?.map(_ConvertP

().toJson).toList(), 22 | 'next_offset': instance.nextOffset, 23 | 'total': instance.total, 24 | 'count': instance.count, 25 | }; 26 | 27 | OnlinePodcast _$OnlinePodcastFromJson(Map json) => 28 | OnlinePodcast( 29 | earliestPubDate: json['earliest_pub_date_ms'] as int?, 30 | title: json['title_original'] as String?, 31 | count: json['total_episodes'] as int?, 32 | description: json['description_original'] as String?, 33 | image: json['image'] as String?, 34 | latestPubDate: json['latest_pub_date_ms'] as int?, 35 | rss: json['rss'] as String?, 36 | publisher: json['publisher_original'] as String?, 37 | id: json['id'] as String?, 38 | ); 39 | 40 | Map _$OnlinePodcastToJson(OnlinePodcast instance) => 41 | { 42 | 'earliest_pub_date_ms': instance.earliestPubDate, 43 | 'title_original': instance.title, 44 | 'rss': instance.rss, 45 | 'latest_pub_date_ms': instance.latestPubDate, 46 | 'description_original': instance.description, 47 | 'total_episodes': instance.count, 48 | 'image': instance.image, 49 | 'publisher_original': instance.publisher, 50 | 'id': instance.id, 51 | }; 52 | -------------------------------------------------------------------------------- /windows/runner/flutter_window.cpp: -------------------------------------------------------------------------------- 1 | #include "flutter_window.h" 2 | 3 | #include 4 | 5 | #include "flutter/generated_plugin_registrant.h" 6 | 7 | FlutterWindow::FlutterWindow(RunLoop* run_loop, 8 | const flutter::DartProject& project) 9 | : run_loop_(run_loop), project_(project) {} 10 | 11 | FlutterWindow::~FlutterWindow() {} 12 | 13 | bool FlutterWindow::OnCreate() { 14 | if (!Win32Window::OnCreate()) { 15 | return false; 16 | } 17 | 18 | RECT frame = GetClientArea(); 19 | 20 | // The size here must match the window dimensions to avoid unnecessary surface 21 | // creation / destruction in the startup path. 22 | flutter_controller_ = std::make_unique( 23 | frame.right - frame.left, frame.bottom - frame.top, project_); 24 | // Ensure that basic setup of the controller was successful. 25 | if (!flutter_controller_->engine() || !flutter_controller_->view()) { 26 | return false; 27 | } 28 | RegisterPlugins(flutter_controller_->engine()); 29 | run_loop_->RegisterFlutterInstance(flutter_controller_->engine()); 30 | SetChildContent(flutter_controller_->view()->GetNativeWindow()); 31 | return true; 32 | } 33 | 34 | void FlutterWindow::OnDestroy() { 35 | if (flutter_controller_) { 36 | run_loop_->UnregisterFlutterInstance(flutter_controller_->engine()); 37 | flutter_controller_ = nullptr; 38 | } 39 | 40 | Win32Window::OnDestroy(); 41 | } 42 | 43 | LRESULT 44 | FlutterWindow::MessageHandler(HWND hwnd, UINT const message, 45 | WPARAM const wparam, 46 | LPARAM const lparam) noexcept { 47 | // Give Flutter, including plugins, an opporutunity to handle window messages. 48 | if (flutter_controller_) { 49 | std::optional result = 50 | flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, 51 | lparam); 52 | if (result) { 53 | return *result; 54 | } 55 | } 56 | 57 | switch (message) { 58 | case WM_FONTCHANGE: 59 | flutter_controller_->engine()->ReloadSystemFonts(); 60 | break; 61 | } 62 | 63 | return Win32Window::MessageHandler(hwnd, message, wparam, lparam); 64 | } 65 | -------------------------------------------------------------------------------- /lib/widgets/custom_list_tile.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | import '../utils/extension_helper.dart'; 4 | 5 | class CustomListTile extends StatelessWidget { 6 | final VoidCallback onTap; 7 | final bool selected; 8 | final Widget? child; 9 | final Widget? leading; 10 | final Widget? trailing; 11 | final String? title; 12 | final String? subtitle; 13 | final EdgeInsets? padding; 14 | 15 | const CustomListTile({ 16 | Key? key, 17 | required this.onTap, 18 | required this.selected, 19 | this.child, 20 | this.padding, 21 | this.leading, 22 | this.trailing, 23 | this.title, 24 | this.subtitle, 25 | }) : assert(child != null || leading != null), 26 | super(key: key); 27 | 28 | @override 29 | Widget build(BuildContext context) { 30 | return Padding( 31 | padding: padding ?? EdgeInsets.all(4.0), 32 | child: InkWell( 33 | borderRadius: BorderRadius.circular(6), 34 | onTap: onTap, 35 | child: Container( 36 | decoration: BoxDecoration( 37 | borderRadius: BorderRadius.circular(6), 38 | color: selected ? context.accentColor : Colors.transparent, 39 | ), 40 | child: child ?? _listItem(context, leading, title!, subtitle!), 41 | ), 42 | ), 43 | ); 44 | } 45 | 46 | Widget _listItem( 47 | BuildContext context, Widget? leading, String title, String subtitle) { 48 | return Row( 49 | children: [ 50 | Padding( 51 | padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 10), 52 | child: leading), 53 | Expanded( 54 | child: Column( 55 | crossAxisAlignment: CrossAxisAlignment.start, 56 | children: [ 57 | Text(title, 58 | maxLines: 1, 59 | style: context.textTheme.bodyText1! 60 | .copyWith(fontWeight: FontWeight.bold)), 61 | Text( 62 | subtitle, 63 | maxLines: 1, 64 | ), 65 | ], 66 | ), 67 | ), 68 | Padding( 69 | padding: const EdgeInsets.symmetric(horizontal: 10), 70 | child: trailing ?? Center(), 71 | ) 72 | ], 73 | ); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /lib/models/episodebrief.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:equatable/equatable.dart'; 4 | import 'package:flutter/material.dart'; 5 | import '../utils/extension_helper.dart'; 6 | 7 | class EpisodeBrief extends Equatable { 8 | final String? title; 9 | final String description; 10 | final int? pubDate; 11 | final int? enclosureLength; 12 | final String enclosureUrl; 13 | final String? feedTitle; 14 | final String? primaryColor; 15 | final int? liked; 16 | final String? downloaded; 17 | final int? duration; 18 | final int? explicit; 19 | final String? imagePath; 20 | final String? mediaId; 21 | final int? isNew; 22 | final int? skipSecondsStart; 23 | final int? skipSecondsEnd; 24 | final int? downloadDate; 25 | EpisodeBrief( 26 | this.title, 27 | this.enclosureUrl, 28 | this.enclosureLength, 29 | this.pubDate, 30 | this.feedTitle, 31 | this.primaryColor, 32 | this.duration, 33 | this.explicit, 34 | this.imagePath, 35 | this.isNew, 36 | {this.mediaId, 37 | this.liked, 38 | this.downloaded, 39 | this.skipSecondsStart, 40 | this.skipSecondsEnd, 41 | this.description = '', 42 | this.downloadDate = 0}); 43 | 44 | ImageProvider get avatarImage { 45 | return (File(imagePath!).existsSync() 46 | ? FileImage(File(imagePath!)) 47 | : const AssetImage('assets/avatar_backup.png')) 48 | as ImageProvider; 49 | } 50 | 51 | Color backgroudColor(BuildContext context) { 52 | return context.brightness == Brightness.light 53 | ? primaryColor!.colorizedark() 54 | : primaryColor!.colorizeLight(); 55 | } 56 | 57 | EpisodeBrief copyWith({ 58 | String? mediaId, 59 | }) => 60 | EpisodeBrief(title, enclosureUrl, enclosureLength, pubDate, feedTitle, 61 | primaryColor, duration, explicit, imagePath, isNew, 62 | mediaId: mediaId ?? this.mediaId, 63 | downloaded: downloaded, 64 | skipSecondsStart: skipSecondsStart, 65 | skipSecondsEnd: skipSecondsEnd, 66 | description: description, 67 | downloadDate: downloadDate); 68 | 69 | @override 70 | List get props => [enclosureUrl, title]; 71 | 72 | @override 73 | String toString() { 74 | return '$title $enclosureUrl'; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /lib/models/service_api/searchpodcast.dart: -------------------------------------------------------------------------------- 1 | import 'dart:ui'; 2 | 3 | import 'package:json_annotation/json_annotation.dart'; 4 | part 'searchpodcast.g.dart'; 5 | 6 | @JsonSerializable() 7 | class SearchPodcast

{ 8 | @_ConvertP() 9 | final List

? results; 10 | @JsonKey(name: 'next_offset') 11 | final int? nextOffset; 12 | final int? total; 13 | final int? count; 14 | SearchPodcast({this.results, this.nextOffset, this.total, this.count}); 15 | factory SearchPodcast.fromJson(Map json) => 16 | _$SearchPodcastFromJson

(json); 17 | Map toJson() => _$SearchPodcastToJson(this); 18 | } 19 | 20 | class _ConvertP

implements JsonConverter { 21 | const _ConvertP(); 22 | @override 23 | P fromJson(Object? json) { 24 | return OnlinePodcast.fromJson(json as Map) as P; 25 | } 26 | 27 | @override 28 | Object? toJson(P object) { 29 | return object; 30 | } 31 | } 32 | 33 | @JsonSerializable() 34 | class OnlinePodcast { 35 | @JsonKey(name: 'earliest_pub_date_ms') 36 | final int? earliestPubDate; 37 | @JsonKey(name: 'title_original') 38 | final String? title; 39 | final String? rss; 40 | @JsonKey(name: 'latest_pub_date_ms') 41 | final int? latestPubDate; 42 | @JsonKey(name: 'description_original') 43 | final String? description; 44 | @JsonKey(name: 'total_episodes') 45 | final int? count; 46 | final String? image; 47 | @JsonKey(name: 'publisher_original') 48 | final String? publisher; 49 | final String? id; 50 | OnlinePodcast( 51 | {this.earliestPubDate, 52 | this.title, 53 | this.count, 54 | this.description, 55 | this.image, 56 | this.latestPubDate, 57 | this.rss, 58 | this.publisher, 59 | this.id}); 60 | factory OnlinePodcast.fromJson(Map json) => 61 | _$OnlinePodcastFromJson(json); 62 | Map toJson() => _$OnlinePodcastToJson(this); 63 | 64 | @override 65 | bool operator ==(Object onlinePodcast) => 66 | onlinePodcast is OnlinePodcast && onlinePodcast.id == id; 67 | 68 | @override 69 | int get hashCode => hashValues(id, title); 70 | 71 | int? get interval { 72 | if (count! < 1) { 73 | // ignore: avoid_returning_null 74 | return null; 75 | } 76 | return (latestPubDate! - earliestPubDate!) ~/ count!; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /windows/runner/run_loop.cpp: -------------------------------------------------------------------------------- 1 | #include "run_loop.h" 2 | 3 | #include 4 | 5 | #include 6 | 7 | RunLoop::RunLoop() {} 8 | 9 | RunLoop::~RunLoop() {} 10 | 11 | void RunLoop::Run() { 12 | bool keep_running = true; 13 | TimePoint next_flutter_event_time = TimePoint::clock::now(); 14 | while (keep_running) { 15 | std::chrono::nanoseconds wait_duration = 16 | std::max(std::chrono::nanoseconds(0), 17 | next_flutter_event_time - TimePoint::clock::now()); 18 | ::MsgWaitForMultipleObjects( 19 | 0, nullptr, FALSE, static_cast(wait_duration.count() / 1000), 20 | QS_ALLINPUT); 21 | bool processed_events = false; 22 | MSG message; 23 | // All pending Windows messages must be processed; MsgWaitForMultipleObjects 24 | // won't return again for items left in the queue after PeekMessage. 25 | while (::PeekMessage(&message, nullptr, 0, 0, PM_REMOVE)) { 26 | processed_events = true; 27 | if (message.message == WM_QUIT) { 28 | keep_running = false; 29 | break; 30 | } 31 | ::TranslateMessage(&message); 32 | ::DispatchMessage(&message); 33 | // Allow Flutter to process messages each time a Windows message is 34 | // processed, to prevent starvation. 35 | next_flutter_event_time = 36 | std::min(next_flutter_event_time, ProcessFlutterMessages()); 37 | } 38 | // If the PeekMessage loop didn't run, process Flutter messages. 39 | if (!processed_events) { 40 | next_flutter_event_time = 41 | std::min(next_flutter_event_time, ProcessFlutterMessages()); 42 | } 43 | } 44 | } 45 | 46 | void RunLoop::RegisterFlutterInstance( 47 | flutter::FlutterEngine* flutter_instance) { 48 | flutter_instances_.insert(flutter_instance); 49 | } 50 | 51 | void RunLoop::UnregisterFlutterInstance( 52 | flutter::FlutterEngine* flutter_instance) { 53 | flutter_instances_.erase(flutter_instance); 54 | } 55 | 56 | RunLoop::TimePoint RunLoop::ProcessFlutterMessages() { 57 | TimePoint next_event_time = TimePoint::max(); 58 | for (auto instance : flutter_instances_) { 59 | std::chrono::nanoseconds wait_duration = instance->ProcessMessages(); 60 | if (wait_duration != std::chrono::nanoseconds::max()) { 61 | next_event_time = 62 | std::min(next_event_time, TimePoint::clock::now() + wait_duration); 63 | } 64 | } 65 | return next_event_time; 66 | } 67 | -------------------------------------------------------------------------------- /lib/service/search_api.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:convert/convert.dart'; 4 | import 'package:crypto/crypto.dart'; 5 | import 'package:dio/dio.dart'; 6 | import '../models/service_api/index_episode.dart'; 7 | import '../models/service_api/index_podcast.dart'; 8 | 9 | const String version = '0.1.0'; 10 | const podcastIndexApi = { 11 | "podcastIndexApiKey": "XXWQEGULBJABVHZUM8NF", 12 | "podcastIndexApiSecret": "KZ2uy4upvq4t3e\$m\$3r2TeFS2fEpFTAaF92xcNdX" 13 | }; 14 | 15 | class PodcastsIndexSearch { 16 | final _dio = Dio(BaseOptions(connectTimeout: 30000, receiveTimeout: 90000)); 17 | final _baseUrl = 'https://api.podcastindex.org'; 18 | Map _initSearch() { 19 | final unixTime = 20 | (DateTime.now().millisecondsSinceEpoch / 1000).round().toString(); 21 | final apiKey = podcastIndexApi['podcastIndexApiKey']!; 22 | final apiSecret = podcastIndexApi['podcastIndexApiSecret']!; 23 | final firstChunk = utf8.encode(apiKey); 24 | final secondChunk = utf8.encode(apiSecret); 25 | final thirdChunk = utf8.encode(unixTime); 26 | var output = AccumulatorSink(); 27 | var input = sha1.startChunkedConversion(output); 28 | input.add(firstChunk); 29 | input.add(secondChunk); 30 | input.add(thirdChunk); 31 | input.close(); 32 | var digest = output.events.single; 33 | 34 | var headers = { 35 | "X-Auth-Date": unixTime, 36 | "X-Auth-Key": apiKey, 37 | "Authorization": digest.toString(), 38 | "User-Agent": "Tsacdop_Desktop/$version" 39 | }; 40 | return headers; 41 | } 42 | 43 | Future> searchPodcasts( 44 | {required String searchText, int? limit = 99}) async { 45 | final url = "$_baseUrl/api/1.0/search/byterm" 46 | "?q=${Uri.encodeComponent(searchText)}&max=$limit&fulltext=true"; 47 | final headers = _initSearch(); 48 | final response = await _dio.get(url, options: Options(headers: headers)); 49 | Map searchResultMap = jsonDecode(response.toString()); 50 | final searchResult = PodcastIndexSearchResult.fromJson(searchResultMap as Map); 51 | return searchResult; 52 | } 53 | 54 | Future> fetchEpisode({String? rssUrl}) async { 55 | final url = "$_baseUrl/api/1.0/episodes/byfeedurl?url=$rssUrl"; 56 | final headers = _initSearch(); 57 | final response = await _dio.get(url, options: Options(headers: headers)); 58 | Map searchResultMap = jsonDecode(response.toString()); 59 | final searchResult = IndexEpisodeResult.fromJson(searchResultMap as Map); 60 | return searchResult; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /lib/models/fireside_data.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:dio/dio.dart'; 4 | import 'package:html/parser.dart'; 5 | import '../storage/sqflite_db.dart'; 6 | 7 | class FiresideData { 8 | final String id; 9 | final String link; 10 | 11 | String? _background; 12 | String? get background => _background; 13 | List? _hosts; 14 | List? get hosts => _hosts; 15 | FiresideData(this.id, this.link); 16 | 17 | final DBHelper _dbHelper = DBHelper(); 18 | 19 | String parseLink(String link) { 20 | if (link == "http://www.shengfm.cn/") { 21 | return "https://guiguzaozhidao.fireside.fm/"; 22 | } else { 23 | return link; 24 | } 25 | } 26 | 27 | Future fatchData() async { 28 | var options = BaseOptions( 29 | connectTimeout: 20000, 30 | receiveTimeout: 20000, 31 | ); 32 | 33 | var response = await Dio(options).get(parseLink(link)); 34 | if (response.statusCode == 200) { 35 | var doc = parse(response.data); 36 | var reg = RegExp(r'https(.+)jpg'); 37 | var backgroundImage = reg.stringMatch(doc.body! 38 | .getElementsByClassName('hero-background') 39 | .first 40 | .attributes 41 | .toString()); 42 | var ul = doc.body!.getElementsByClassName('episode-hosts').first.children; 43 | var hosts = []; 44 | for (var element in ul) { 45 | PodcastHost host; 46 | var name = element.text.trim(); 47 | var image = element.children.first.children.first.attributes.toString(); 48 | host = PodcastHost( 49 | name, 50 | reg.stringMatch(image) ?? 51 | 'https://fireside.fm/assets/default/avatar_small' 52 | '-170afdc2be97fc6148b283083942d82c101d4c1061f6b28f87c8958b52664af9.jpg'); 53 | 54 | hosts.add(host); 55 | } 56 | var data = [ 57 | id, 58 | backgroundImage, 59 | json.encode({'hosts': hosts.map((host) => host.toJson()).toList()}) 60 | ]; 61 | await _dbHelper.saveFiresideData(data); 62 | } 63 | } 64 | 65 | Future getData() async { 66 | var data = await _dbHelper.getFiresideData(id); 67 | _background = data[0]; 68 | if (data[1] != '') { 69 | _hosts = json 70 | .decode(data[1]!)['hosts'] 71 | .cast>() 72 | .map(PodcastHost.fromJson) 73 | .toList(); 74 | } else { 75 | _hosts = null; 76 | } 77 | } 78 | } 79 | 80 | class PodcastHost { 81 | final String? image; 82 | final String? name; 83 | PodcastHost(this.name, this.image); 84 | 85 | Map toJson() { 86 | return {'name': name, 'image': image}; 87 | } 88 | 89 | static PodcastHost fromJson(Map json) { 90 | return PodcastHost(json['name'] as String?, json['image'] as String?); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /lib/widgets/custom_slider.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class MyRectangularTrackShape extends RectangularSliderTrackShape { 4 | Rect getPreferredRect({ 5 | required RenderBox parentBox, 6 | Offset offset = Offset.zero, 7 | required SliderThemeData sliderTheme, 8 | bool isEnabled = false, 9 | bool isDiscrete = false, 10 | }) { 11 | final trackHeight = sliderTheme.trackHeight!; 12 | final trackLeft = offset.dx; 13 | final trackTop = offset.dy + (parentBox.size.height - trackHeight) / 2; 14 | final trackWidth = parentBox.size.width; 15 | return Rect.fromLTWH(trackLeft - 5, trackTop, trackWidth, trackHeight); 16 | } 17 | } 18 | 19 | class MyRoundSliderThumpShape extends SliderComponentShape { 20 | const MyRoundSliderThumpShape({ 21 | this.enabledThumbRadius = 10.0, 22 | this.disabledThumbRadius, 23 | this.thumbCenterColor, 24 | }); 25 | final Color? thumbCenterColor; 26 | final double enabledThumbRadius; 27 | final double? disabledThumbRadius; 28 | double get _disabledThumbRadius => disabledThumbRadius ?? enabledThumbRadius; 29 | 30 | @override 31 | Size getPreferredSize(bool isEnabled, bool isDiscrete) { 32 | return Size.fromRadius( 33 | isEnabled == true ? enabledThumbRadius : _disabledThumbRadius); 34 | } 35 | 36 | @override 37 | void paint( 38 | PaintingContext context, 39 | Offset center, { 40 | Animation? activationAnimation, 41 | required Animation enableAnimation, 42 | bool? isDiscrete, 43 | TextPainter? labelPainter, 44 | RenderBox? parentBox, 45 | required SliderThemeData sliderTheme, 46 | TextDirection? textDirection, 47 | double? value, 48 | double? textScaleFactor, 49 | Size? sizeWithOverflow, 50 | }) { 51 | final canvas = context.canvas; 52 | final radiusTween = Tween( 53 | begin: _disabledThumbRadius, 54 | end: enabledThumbRadius, 55 | ); 56 | // final ColorTween colorTween = ColorTween( 57 | // begin: sliderTheme.disabledThumbColor, 58 | // end: sliderTheme.thumbColor, 59 | // ); 60 | 61 | canvas.drawCircle( 62 | center, 63 | radiusTween.evaluate(enableAnimation), 64 | Paint() 65 | ..color = thumbCenterColor! 66 | ..style = PaintingStyle.fill 67 | ..strokeWidth = 2, 68 | ); 69 | 70 | canvas.drawRect( 71 | Rect.fromLTRB( 72 | center.dx - 10, center.dy + 10, center.dx + 10, center.dy - 10), 73 | Paint() 74 | ..color = Colors.white 75 | ..style = PaintingStyle.fill 76 | ..strokeWidth = 10, 77 | ); 78 | 79 | canvas.drawLine( 80 | Offset(center.dx - 5, center.dy - 2), 81 | Offset(center.dx + 5, center.dy + 2), 82 | Paint() 83 | ..color = Colors.transparent 84 | ..style = PaintingStyle.fill 85 | ..strokeWidth = 2, 86 | ); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /linux/flutter/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required(VERSION 3.10) 2 | 3 | set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") 4 | 5 | # Configuration provided via flutter tool. 6 | include(${EPHEMERAL_DIR}/generated_config.cmake) 7 | 8 | # TODO: Move the rest of this into files in ephemeral. See 9 | # https://github.com/flutter/flutter/issues/57146. 10 | 11 | # Serves the same purpose as list(TRANSFORM ... PREPEND ...), 12 | # which isn't available in 3.10. 13 | function(list_prepend LIST_NAME PREFIX) 14 | set(NEW_LIST "") 15 | foreach(element ${${LIST_NAME}}) 16 | list(APPEND NEW_LIST "${PREFIX}${element}") 17 | endforeach(element) 18 | set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) 19 | endfunction() 20 | 21 | # === Flutter Library === 22 | # System-level dependencies. 23 | find_package(PkgConfig REQUIRED) 24 | pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) 25 | pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) 26 | pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) 27 | 28 | set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") 29 | 30 | # Published to parent scope for install step. 31 | set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) 32 | set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) 33 | set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) 34 | set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) 35 | 36 | list(APPEND FLUTTER_LIBRARY_HEADERS 37 | "fl_basic_message_channel.h" 38 | "fl_binary_codec.h" 39 | "fl_binary_messenger.h" 40 | "fl_dart_project.h" 41 | "fl_engine.h" 42 | "fl_json_message_codec.h" 43 | "fl_json_method_codec.h" 44 | "fl_message_codec.h" 45 | "fl_method_call.h" 46 | "fl_method_channel.h" 47 | "fl_method_codec.h" 48 | "fl_method_response.h" 49 | "fl_plugin_registrar.h" 50 | "fl_plugin_registry.h" 51 | "fl_standard_message_codec.h" 52 | "fl_standard_method_codec.h" 53 | "fl_string_codec.h" 54 | "fl_value.h" 55 | "fl_view.h" 56 | "flutter_linux.h" 57 | ) 58 | list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") 59 | add_library(flutter INTERFACE) 60 | target_include_directories(flutter INTERFACE 61 | "${EPHEMERAL_DIR}" 62 | ) 63 | target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") 64 | target_link_libraries(flutter INTERFACE 65 | PkgConfig::GTK 66 | PkgConfig::GLIB 67 | PkgConfig::GIO 68 | ) 69 | add_dependencies(flutter flutter_assemble) 70 | 71 | # === Flutter tool backend === 72 | # _phony_ is a non-existent file to force this command to run every time, 73 | # since currently there's no way to get a full input/output list from the 74 | # flutter tool. 75 | add_custom_command( 76 | OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} 77 | ${CMAKE_CURRENT_BINARY_DIR}/_phony_ 78 | COMMAND ${CMAKE_COMMAND} -E env 79 | ${FLUTTER_TOOL_ENVIRONMENT} 80 | "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" 81 | ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} 82 | VERBATIM 83 | ) 84 | add_custom_target(flutter_assemble DEPENDS 85 | "${FLUTTER_LIBRARY}" 86 | ${FLUTTER_LIBRARY_HEADERS} 87 | ) 88 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## About 2 | 3 | Enjoy podcasts with Tsacdop! 4 | Tsacdop is a podcast player developed with Flutter, a clean, simply beautiful and friendly app, which is also free and open source. 5 | This repo is windows version of Tsacdop, this is still on early stage. 6 | 7 | Release soon. 8 | ## Preview 9 | 10 | ![](preview/linux_home.png) 11 | 12 | ## Build 13 | 14 | 1. If you don't have Flutter SDK installed, please visit offcial [Flutter][Flutter Install] site. 15 | 16 | You need to upgrade to dev channel to build windows app. visit [https://flutter.dev/desktop](https://flutter.dev/desktop) for more info. 17 | 18 | ``` 19 | flutter channel beta 20 | flutter upgrade 21 | flutter config --enable-windows-desktop 22 | ``` 23 | 24 | 1. Fetch latest sorce code from master branch. 25 | 26 | ``` 27 | git clone https://github.com/tsacdop/tsacdop_desktop.git 28 | ``` 29 | 30 | 3. Add api search api configure file. 31 | 32 | Tsacdop desktop uses PodcsastIndex API pro to search for podcasts If you want to build the app, you need to create a new file named `.env.dart` in lib folder. Add the following code to `.env.dart` . You can get your own API key on [PodcastIndex](https://podcastindex.org/), it is free to all. 33 | 34 | ``` dart 35 | final environment = { 36 | "podcastIndexApiKey": "XXXXXXXX", 37 | "podcastIndexApiSecret": "XXXXXXXXXXXXXXXXXX" 38 | }; 39 | ``` 40 | 41 | 4. Run the app with Android Studio or Visual Studio. Or the command line. 42 | 43 | ``` 44 | flutter pub get 45 | flutter run 46 | ``` 47 | 48 | ## Archetecture 49 | 50 | ### Plugins 51 | 52 | * Local storage 53 | + sqflite 54 | + shared_preferences 55 | * Audio 56 | + dart_vlc 57 | * State management 58 | + riverpod 59 | * Download 60 | + dio 61 | 62 | ## Contact 63 | 64 | You can reach out to me directly at [tsacdop.app@gmail.com](mailto:). 65 | 66 | Or you can join our [Telegram Group](https://t.me/joinchat/Bk3LkRpTHy40QYC78PK7Qg) 67 | 68 | ## Credit 69 | 70 | [Alexmercerind](https://github.com/alexmercerind/) 71 | 72 | Thanks for the plugin and I also learned a lot from Harmonoid. 73 | 74 | 75 | 76 | [Homepage Screenshot]: https://raw.githubusercontent.com/stonega/tsacdop_desktop/master/preview/linux_home.png 77 | [Dark Mode]: https://raw.githubusercontent.com/stonega/tsacdop_desktop/master/preview/homepage.png 78 | [github release]: https://img.shields.io/github/v/release/stonega/tsacdop_desktop 79 | [github release - recent]: https://github.com/stonega/tsacdop_desktop/releases 80 | [github downloads]: https://img.shields.io/github/downloads/stonega/tsacdop_desktop/total?color=%230000d&label=downloads 81 | [localizely]: https://img.shields.io/badge/dynamic/json?color=%2326c6da&label=localizely&query=%24.languages.length&url=https%3A%2F%2Fapi.localizely.com%2Fv1%2Fprojects%2Fbde4e9bd-4cb2-449b-9de2-18f231ddb47d%2Fstatus 82 | [style: effective dart]: https://img.shields.io/badge/style-effective_dart-40c4ff.svg 83 | [effective dart pub]: https://pub.dev/packages/effective_dart 84 | [license]: https://github.com/stonega/tsacdop/blob/master/LICENSE 85 | [License badge]: https://img.shields.io/badge/license-GPLv3-yellow.svg 86 | -------------------------------------------------------------------------------- /windows/runner/Runner.rc: -------------------------------------------------------------------------------- 1 | // Microsoft Visual C++ generated resource script. 2 | // 3 | #pragma code_page(65001) 4 | #include "resource.h" 5 | 6 | #define APSTUDIO_READONLY_SYMBOLS 7 | ///////////////////////////////////////////////////////////////////////////// 8 | // 9 | // Generated from the TEXTINCLUDE 2 resource. 10 | // 11 | #include "winres.h" 12 | 13 | ///////////////////////////////////////////////////////////////////////////// 14 | #undef APSTUDIO_READONLY_SYMBOLS 15 | 16 | ///////////////////////////////////////////////////////////////////////////// 17 | // English (United States) resources 18 | 19 | #if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) 20 | LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US 21 | 22 | #ifdef APSTUDIO_INVOKED 23 | ///////////////////////////////////////////////////////////////////////////// 24 | // 25 | // TEXTINCLUDE 26 | // 27 | 28 | 1 TEXTINCLUDE 29 | BEGIN 30 | "resource.h\0" 31 | END 32 | 33 | 2 TEXTINCLUDE 34 | BEGIN 35 | "#include ""winres.h""\r\n" 36 | "\0" 37 | END 38 | 39 | 3 TEXTINCLUDE 40 | BEGIN 41 | "\r\n" 42 | "\0" 43 | END 44 | 45 | #endif // APSTUDIO_INVOKED 46 | 47 | 48 | ///////////////////////////////////////////////////////////////////////////// 49 | // 50 | // Icon 51 | // 52 | 53 | // Icon with lowest ID value placed first to ensure application icon 54 | // remains consistent on all systems. 55 | IDI_APP_ICON ICON "resources\\app_icon.ico" 56 | 57 | 58 | ///////////////////////////////////////////////////////////////////////////// 59 | // 60 | // Version 61 | // 62 | 63 | #ifdef FLUTTER_BUILD_NUMBER 64 | #define VERSION_AS_NUMBER FLUTTER_BUILD_NUMBER 65 | #else 66 | #define VERSION_AS_NUMBER 1,0,0 67 | #endif 68 | 69 | #ifdef FLUTTER_BUILD_NAME 70 | #define VERSION_AS_STRING #FLUTTER_BUILD_NAME 71 | #else 72 | #define VERSION_AS_STRING "1.0.0" 73 | #endif 74 | 75 | VS_VERSION_INFO VERSIONINFO 76 | FILEVERSION VERSION_AS_NUMBER 77 | PRODUCTVERSION VERSION_AS_NUMBER 78 | FILEFLAGSMASK VS_FFI_FILEFLAGSMASK 79 | #ifdef _DEBUG 80 | FILEFLAGS VS_FF_DEBUG 81 | #else 82 | FILEFLAGS 0x0L 83 | #endif 84 | FILEOS VOS__WINDOWS32 85 | FILETYPE VFT_APP 86 | FILESUBTYPE 0x0L 87 | BEGIN 88 | BLOCK "StringFileInfo" 89 | BEGIN 90 | BLOCK "040904e4" 91 | BEGIN 92 | VALUE "CompanyName", "Tsacdop" "\0" 93 | VALUE "FileDescription", "Enjoy podcasts with Tsacdop." "\0" 94 | VALUE "FileVersion", VERSION_AS_STRING "\0" 95 | VALUE "InternalName", "tsacdop_desktop" "\0" 96 | VALUE "LegalCopyright", "Copyright (C) 2020 Tsacdop. All rights reserved." "\0" 97 | VALUE "OriginalFilename", "tsacdop_desktop.exe" "\0" 98 | VALUE "ProductName", "Tsacdop Desktop" "\0" 99 | VALUE "ProductVersion", VERSION_AS_STRING "\0" 100 | END 101 | END 102 | BLOCK "VarFileInfo" 103 | BEGIN 104 | VALUE "Translation", 0x409, 1252 105 | END 106 | END 107 | 108 | #endif // English (United States) resources 109 | ///////////////////////////////////////////////////////////////////////////// 110 | 111 | 112 | 113 | #ifndef APSTUDIO_INVOKED 114 | ///////////////////////////////////////////////////////////////////////////// 115 | // 116 | // Generated from the TEXTINCLUDE 3 resource. 117 | // 118 | 119 | 120 | ///////////////////////////////////////////////////////////////////////////// 121 | #endif // not APSTUDIO_INVOKED 122 | -------------------------------------------------------------------------------- /lib/storage/key_value_storage.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:shared_preferences/shared_preferences.dart'; 4 | import 'package:tsacdop_desktop/providers/group_state.dart'; 5 | 6 | const String themesKey = 'themesKey'; 7 | const String accentColorKey = 'accentColorKey'; 8 | const String realDarkKey = 'realDarkKey'; 9 | const String searchHistoryKey = 'searchHistoryKey'; 10 | const String groupsKey = 'groupsKey'; 11 | const String podcastLayoutKey = 'podcastLayoutKey'; 12 | const String hideListenedKey = 'hideListenedKey'; 13 | const String recentLayoutKey = 'recentLayoutKey'; 14 | const String favLayoutKey = 'favLayoutKey'; 15 | const String refreshDateKey = 'refreshDateKey'; 16 | const String downloadLayoutKey = 'downloadLayoutKey'; 17 | const String playlistKey = 'playlistKey'; 18 | const String localeKey = 'localeKey'; 19 | const String proxyKey = 'proxyKey'; 20 | 21 | class KeyValueStorage { 22 | final String key; 23 | KeyValueStorage(this.key); 24 | 25 | Future saveInt(int setting) async { 26 | var prefs = await SharedPreferences.getInstance(); 27 | return prefs.setInt(key, setting); 28 | } 29 | 30 | Future getInt({int defaultValue = 0}) async { 31 | var prefs = await SharedPreferences.getInstance(); 32 | if (prefs.getInt(key) == null) await prefs.setInt(key, defaultValue); 33 | return prefs.getInt(key) ?? defaultValue; 34 | } 35 | 36 | Future saveStringList(List playList) async { 37 | var prefs = await SharedPreferences.getInstance(); 38 | return prefs.setStringList(key, playList); 39 | } 40 | 41 | Future> getStringList() async { 42 | var prefs = await SharedPreferences.getInstance(); 43 | if (prefs.getStringList(key) == null) { 44 | await prefs.setStringList(key, []); 45 | } 46 | return prefs.getStringList(key) ?? []; 47 | } 48 | 49 | Future saveString(String string) async { 50 | var prefs = await SharedPreferences.getInstance(); 51 | return prefs.setString(key, string); 52 | } 53 | 54 | Future getString() async { 55 | var prefs = await SharedPreferences.getInstance(); 56 | if (prefs.getString(key) == null) { 57 | await prefs.setString(key, ''); 58 | } 59 | return prefs.getString(key); 60 | } 61 | 62 | Future getBool({required bool defaultValue}) async { 63 | var prefs = await SharedPreferences.getInstance(); 64 | if (prefs.getBool(key) == null) { 65 | await prefs.setBool(key, defaultValue); 66 | } 67 | var value = prefs.getBool(key); 68 | return value; 69 | } 70 | 71 | Future saveBool(value) async { 72 | var prefs = await SharedPreferences.getInstance(); 73 | return await prefs.setBool(key, value); 74 | } 75 | 76 | Future> getGroups() async { 77 | var prefs = await SharedPreferences.getInstance(); 78 | if (prefs.getString(key) == null) { 79 | var home = PodcastGroup('Home'); 80 | await prefs.setString( 81 | key, 82 | json.encode({ 83 | 'groups': [home.toEntity().toJson()] 84 | })); 85 | } 86 | 87 | return json 88 | .decode(prefs.getString(key)!)['groups'] 89 | .cast>() 90 | .map(GroupEntity.fromJson) 91 | .toList(growable: false); 92 | } 93 | 94 | Future saveGroup(List groupList) async { 95 | var prefs = await SharedPreferences.getInstance(); 96 | return prefs.setString( 97 | key, 98 | json.encode( 99 | {'groups': groupList.map((group) => group.toJson()).toList()})); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /windows/runner/win32_window.h: -------------------------------------------------------------------------------- 1 | #ifndef RUNNER_WIN32_WINDOW_H_ 2 | #define RUNNER_WIN32_WINDOW_H_ 3 | 4 | #include 5 | 6 | #include 7 | #include 8 | #include 9 | 10 | // A class abstraction for a high DPI-aware Win32 Window. Intended to be 11 | // inherited from by classes that wish to specialize with custom 12 | // rendering and input handling 13 | class Win32Window { 14 | public: 15 | struct Point { 16 | unsigned int x; 17 | unsigned int y; 18 | Point(unsigned int x, unsigned int y) : x(x), y(y) {} 19 | }; 20 | 21 | struct Size { 22 | unsigned int width; 23 | unsigned int height; 24 | Size(unsigned int width, unsigned int height) 25 | : width(width), height(height) {} 26 | }; 27 | 28 | Win32Window(); 29 | virtual ~Win32Window(); 30 | 31 | // Creates and shows a win32 window with |title| and position and size using 32 | // |origin| and |size|. New windows are created on the default monitor. Window 33 | // sizes are specified to the OS in physical pixels, hence to ensure a 34 | // consistent size to will treat the width height passed in to this function 35 | // as logical pixels and scale to appropriate for the default monitor. Returns 36 | // true if the window was created successfully. 37 | bool CreateAndShow(const std::wstring& title, 38 | const Point& origin, 39 | const Size& size); 40 | 41 | // Release OS resources associated with window. 42 | void Destroy(); 43 | 44 | // Inserts |content| into the window tree. 45 | void SetChildContent(HWND content); 46 | 47 | // Returns the backing Window handle to enable clients to set icon and other 48 | // window properties. Returns nullptr if the window has been destroyed. 49 | HWND GetHandle(); 50 | 51 | // If true, closing this window will quit the application. 52 | void SetQuitOnClose(bool quit_on_close); 53 | 54 | // Return a RECT representing the bounds of the current client area. 55 | RECT GetClientArea(); 56 | 57 | protected: 58 | // Processes and route salient window messages for mouse handling, 59 | // size change and DPI. Delegates handling of these to member overloads that 60 | // inheriting classes can handle. 61 | virtual LRESULT MessageHandler(HWND window, 62 | UINT const message, 63 | WPARAM const wparam, 64 | LPARAM const lparam) noexcept; 65 | 66 | // Called when CreateAndShow is called, allowing subclass window-related 67 | // setup. Subclasses should return false if setup fails. 68 | virtual bool OnCreate(); 69 | 70 | // Called when Destroy is called. 71 | virtual void OnDestroy(); 72 | 73 | private: 74 | friend class WindowClassRegistrar; 75 | 76 | // OS callback called by message pump. Handles the WM_NCCREATE message which 77 | // is passed when the non-client area is being created and enables automatic 78 | // non-client DPI scaling so that the non-client area automatically 79 | // responsponds to changes in DPI. All other messages are handled by 80 | // MessageHandler. 81 | static LRESULT CALLBACK WndProc(HWND const window, 82 | UINT const message, 83 | WPARAM const wparam, 84 | LPARAM const lparam) noexcept; 85 | 86 | // Retrieves a class instance pointer for |window| 87 | static Win32Window* GetThisFromHandle(HWND const window) noexcept; 88 | 89 | bool quit_on_close_ = false; 90 | 91 | // window handle for top level window. 92 | HWND window_handle_ = nullptr; 93 | 94 | // window handle for hosted content. 95 | HWND child_content_ = nullptr; 96 | }; 97 | 98 | #endif // RUNNER_WIN32_WINDOW_H_ 99 | -------------------------------------------------------------------------------- /windows/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required(VERSION 3.15) 2 | project(tsacdop_desktop LANGUAGES CXX) 3 | 4 | set(BINARY_NAME "tsacdop_desktop") 5 | 6 | cmake_policy(SET CMP0063 NEW) 7 | 8 | set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") 9 | 10 | # Configure build options. 11 | get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) 12 | if(IS_MULTICONFIG) 13 | set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" 14 | CACHE STRING "" FORCE) 15 | else() 16 | if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) 17 | set(CMAKE_BUILD_TYPE "Debug" CACHE 18 | STRING "Flutter build mode" FORCE) 19 | set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS 20 | "Debug" "Profile" "Release") 21 | endif() 22 | endif() 23 | 24 | set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") 25 | set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") 26 | set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") 27 | set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") 28 | 29 | # Use Unicode for all projects. 30 | add_definitions(-DUNICODE -D_UNICODE) 31 | 32 | # Compilation settings that should be applied to most targets. 33 | function(APPLY_STANDARD_SETTINGS TARGET) 34 | target_compile_features(${TARGET} PUBLIC cxx_std_17) 35 | target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") 36 | target_compile_options(${TARGET} PRIVATE /EHsc) 37 | target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") 38 | target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") 39 | endfunction() 40 | 41 | set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") 42 | 43 | # Flutter library and tool build rules. 44 | add_subdirectory(${FLUTTER_MANAGED_DIR}) 45 | 46 | # Application build 47 | add_subdirectory("runner") 48 | 49 | # Generated plugin build rules, which manage building the plugins and adding 50 | # them to the application. 51 | include(flutter/generated_plugins.cmake) 52 | 53 | 54 | # === Installation === 55 | # Support files are copied into place next to the executable, so that it can 56 | # run in place. This is done instead of making a separate bundle (as on Linux) 57 | # so that building and running from within Visual Studio will work. 58 | set(BUILD_BUNDLE_DIR "$") 59 | # Make the "install" step default, as it's required to run. 60 | set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) 61 | if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) 62 | set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) 63 | endif() 64 | 65 | set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") 66 | set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") 67 | 68 | install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" 69 | COMPONENT Runtime) 70 | 71 | install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" 72 | COMPONENT Runtime) 73 | 74 | install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" 75 | COMPONENT Runtime) 76 | 77 | if(PLUGIN_BUNDLED_LIBRARIES) 78 | install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" 79 | DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" 80 | COMPONENT Runtime) 81 | endif() 82 | 83 | # Fully re-copy the assets directory on each build to avoid having stale files 84 | # from a previous install. 85 | set(FLUTTER_ASSET_DIR_NAME "flutter_assets") 86 | install(CODE " 87 | file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") 88 | " COMPONENT Runtime) 89 | install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" 90 | DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) 91 | 92 | # Install the AOT library on non-Debug builds only. 93 | install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" 94 | CONFIGURATIONS Profile;Release 95 | COMPONENT Runtime) 96 | -------------------------------------------------------------------------------- /lib/utils/extension_helper.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:developer' as developer; 3 | 4 | import 'package:flutter/material.dart'; 5 | import 'package:intl/intl.dart'; 6 | import 'package:url_launcher/url_launcher.dart'; 7 | import '../generated/l10n.dart'; 8 | 9 | extension ContextExtension on BuildContext { 10 | Color get primaryColor => Theme.of(this).primaryColor; 11 | Color get accentColor => Theme.of(this).colorScheme.secondary; 12 | Color get scaffoldBackgroundColor => Theme.of(this).scaffoldBackgroundColor; 13 | Color get primaryColorDark => Theme.of(this).primaryColorDark; 14 | Color? get textColor => Theme.of(this).textTheme.bodyText1!.color; 15 | Color get dialogBackgroundColor => Theme.of(this).dialogBackgroundColor; 16 | Brightness get brightness => Theme.of(this).brightness; 17 | double get width => MediaQuery.of(this).size.width; 18 | double get height => MediaQuery.of(this).size.height; 19 | double get paddingTop => MediaQuery.of(this).padding.top; 20 | TextTheme get textTheme => Theme.of(this).textTheme; 21 | S? get s => S.of(this); 22 | } 23 | 24 | extension IntExtension on int { 25 | String toDate(BuildContext context) { 26 | final s = context.s; 27 | var date = DateTime.fromMillisecondsSinceEpoch(this, isUtc: true); 28 | var difference = DateTime.now().toUtc().difference(date); 29 | if (difference.inMinutes < 30) { 30 | return s!.minsAgo(difference.inMinutes); 31 | } else if (difference.inMinutes < 60) { 32 | return s!.hoursAgo(0); 33 | } else if (difference.inHours < 24) { 34 | return s!.hoursAgo(difference.inHours); 35 | } else if (difference.inDays < 7) { 36 | return s!.daysAgo(difference.inDays); 37 | } else { 38 | return DateFormat.yMMMd().format( 39 | DateTime.fromMillisecondsSinceEpoch(this, isUtc: true).toLocal()); 40 | } 41 | } 42 | 43 | String get toTime => 44 | '${(this ~/ 60).toString().padLeft(2, '0')}:${(truncate() % 60).toString().padLeft(2, '0')}'; 45 | 46 | String toInterval(BuildContext context) { 47 | if (isNegative) return ''; 48 | final s = context.s; 49 | var interval = Duration(milliseconds: this); 50 | if (interval.inHours <= 48) { 51 | return s!.publishedDaily; 52 | } else if (interval.inDays > 2 && interval.inDays <= 14) { 53 | return s!.publishedWeekly; 54 | } else if (interval.inDays > 14 && interval.inDays < 60) { 55 | return s!.publishedMonthly; 56 | } else { 57 | return s!.publishedYearly; 58 | } 59 | } 60 | } 61 | 62 | extension StringExtension on String { 63 | Future get launchUrl async { 64 | if (await canLaunch(this)) { 65 | await launch(this); 66 | } else { 67 | developer.log('Could not launch $this'); 68 | } 69 | } 70 | 71 | Color colorizedark() { 72 | Color c; 73 | if (!this.contains('[')) { 74 | return Colors.blue; 75 | } 76 | var color = json.decode(this); 77 | if (color[0] > 200 && color[1] > 200 && color[2] > 200) { 78 | c = Color.fromRGBO(255 - color[0] as int, 255 - color[1] as int, 255 - color[2] as int, 1.0); 79 | } else { 80 | c = Color.fromRGBO(color[0], color[1] > 200 ? 190 : color[1], 81 | color[2] > 200 ? 190 : color[2], 1); 82 | } 83 | return c; 84 | } 85 | 86 | Color colorizeLight() { 87 | Color c; 88 | if (!this.contains('[')) { 89 | return Colors.blue; 90 | } 91 | var color = json.decode(this); 92 | 93 | if (color[0] < 50 && color[1] < 50 && color[2] < 50) { 94 | c = Color.fromRGBO(255 - color[0] as int, 255 - color[1] as int, 255 - color[2] as int, 1.0); 95 | } else { 96 | c = Color.fromRGBO(color[0] < 50 ? 100 : color[0], 97 | color[1] < 50 ? 100 : color[1], color[2] < 50 ? 100 : color[2], 1.0); 98 | } 99 | return c; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /lib/generated/intl/messages_all.dart: -------------------------------------------------------------------------------- 1 | // DO NOT EDIT. This is code generated via package:intl/generate_localized.dart 2 | // This is a library that looks up messages for specific locales by 3 | // delegating to the appropriate library. 4 | 5 | // Ignore issues from commonly used lints in this file. 6 | // ignore_for_file:implementation_imports, file_names, unnecessary_new 7 | // ignore_for_file:unnecessary_brace_in_string_interps, directives_ordering 8 | // ignore_for_file:argument_type_not_assignable, invalid_assignment 9 | // ignore_for_file:prefer_single_quotes, prefer_generic_function_type_aliases 10 | // ignore_for_file:comment_references 11 | 12 | import 'dart:async'; 13 | 14 | import 'package:intl/intl.dart'; 15 | import 'package:intl/message_lookup_by_library.dart'; 16 | import 'package:intl/src/intl_helpers.dart'; 17 | 18 | import 'messages_az.dart' as messages_az; 19 | import 'messages_de.dart' as messages_de; 20 | import 'messages_en.dart' as messages_en; 21 | import 'messages_es.dart' as messages_es; 22 | import 'messages_fr.dart' as messages_fr; 23 | import 'messages_hi.dart' as messages_hi; 24 | import 'messages_it.dart' as messages_it; 25 | import 'messages_no.dart' as messages_no; 26 | import 'messages_pt.dart' as messages_pt; 27 | import 'messages_ru.dart' as messages_ru; 28 | import 'messages_zh-Hans.dart' as messages_zh_hans; 29 | 30 | typedef Future LibraryLoader(); 31 | Map _deferredLibraries = { 32 | 'az': () => new Future.value(null), 33 | 'de': () => new Future.value(null), 34 | 'en': () => new Future.value(null), 35 | 'es': () => new Future.value(null), 36 | 'fr': () => new Future.value(null), 37 | 'hi': () => new Future.value(null), 38 | 'it': () => new Future.value(null), 39 | 'no': () => new Future.value(null), 40 | 'pt': () => new Future.value(null), 41 | 'ru': () => new Future.value(null), 42 | 'zh_Hans': () => new Future.value(null), 43 | }; 44 | 45 | MessageLookupByLibrary? _findExact(String localeName) { 46 | switch (localeName) { 47 | case 'az': 48 | return messages_az.messages; 49 | case 'de': 50 | return messages_de.messages; 51 | case 'en': 52 | return messages_en.messages; 53 | case 'es': 54 | return messages_es.messages; 55 | case 'fr': 56 | return messages_fr.messages; 57 | case 'hi': 58 | return messages_hi.messages; 59 | case 'it': 60 | return messages_it.messages; 61 | case 'no': 62 | return messages_no.messages; 63 | case 'pt': 64 | return messages_pt.messages; 65 | case 'ru': 66 | return messages_ru.messages; 67 | case 'zh_Hans': 68 | return messages_zh_hans.messages; 69 | default: 70 | return null; 71 | } 72 | } 73 | 74 | /// User programs should call this before using [localeName] for messages. 75 | Future initializeMessages(String localeName) async { 76 | var availableLocale = Intl.verifiedLocale( 77 | localeName, (locale) => _deferredLibraries[locale] != null, 78 | onFailure: (_) => null); 79 | if (availableLocale == null) { 80 | return new Future.value(false); 81 | } 82 | var lib = _deferredLibraries[availableLocale]; 83 | await (lib == null ? new Future.value(false) : lib()); 84 | initializeInternalMessageLookup(() => new CompositeMessageLookup()); 85 | messageLookup.addLocale(availableLocale, _findGeneratedMessagesFor); 86 | return new Future.value(true); 87 | } 88 | 89 | bool _messagesExistFor(String locale) { 90 | try { 91 | return _findExact(locale) != null; 92 | } catch (e) { 93 | return false; 94 | } 95 | } 96 | 97 | MessageLookupByLibrary? _findGeneratedMessagesFor(String locale) { 98 | var actualLocale = 99 | Intl.verifiedLocale(locale, _messagesExistFor, onFailure: (_) => null); 100 | if (actualLocale == null) return null; 101 | return _findExact(actualLocale); 102 | } 103 | -------------------------------------------------------------------------------- /windows/flutter/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required(VERSION 3.15) 2 | 3 | set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") 4 | 5 | # Configuration provided via flutter tool. 6 | include(${EPHEMERAL_DIR}/generated_config.cmake) 7 | 8 | # TODO: Move the rest of this into files in ephemeral. See 9 | # https://github.com/flutter/flutter/issues/57146. 10 | set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") 11 | 12 | # === Flutter Library === 13 | set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") 14 | 15 | # Published to parent scope for install step. 16 | set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) 17 | set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) 18 | set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) 19 | set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) 20 | 21 | list(APPEND FLUTTER_LIBRARY_HEADERS 22 | "flutter_export.h" 23 | "flutter_windows.h" 24 | "flutter_messenger.h" 25 | "flutter_plugin_registrar.h" 26 | ) 27 | list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") 28 | add_library(flutter INTERFACE) 29 | target_include_directories(flutter INTERFACE 30 | "${EPHEMERAL_DIR}" 31 | ) 32 | target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") 33 | add_dependencies(flutter flutter_assemble) 34 | 35 | # === Wrapper === 36 | list(APPEND CPP_WRAPPER_SOURCES_CORE 37 | "core_implementations.cc" 38 | "standard_codec.cc" 39 | ) 40 | list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") 41 | list(APPEND CPP_WRAPPER_SOURCES_PLUGIN 42 | "plugin_registrar.cc" 43 | ) 44 | list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") 45 | list(APPEND CPP_WRAPPER_SOURCES_APP 46 | "flutter_engine.cc" 47 | "flutter_view_controller.cc" 48 | ) 49 | list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") 50 | 51 | # Wrapper sources needed for a plugin. 52 | add_library(flutter_wrapper_plugin STATIC 53 | ${CPP_WRAPPER_SOURCES_CORE} 54 | ${CPP_WRAPPER_SOURCES_PLUGIN} 55 | ) 56 | apply_standard_settings(flutter_wrapper_plugin) 57 | set_target_properties(flutter_wrapper_plugin PROPERTIES 58 | POSITION_INDEPENDENT_CODE ON) 59 | set_target_properties(flutter_wrapper_plugin PROPERTIES 60 | CXX_VISIBILITY_PRESET hidden) 61 | target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) 62 | target_include_directories(flutter_wrapper_plugin PUBLIC 63 | "${WRAPPER_ROOT}/include" 64 | ) 65 | add_dependencies(flutter_wrapper_plugin flutter_assemble) 66 | 67 | # Wrapper sources needed for the runner. 68 | add_library(flutter_wrapper_app STATIC 69 | ${CPP_WRAPPER_SOURCES_CORE} 70 | ${CPP_WRAPPER_SOURCES_APP} 71 | ) 72 | apply_standard_settings(flutter_wrapper_app) 73 | target_link_libraries(flutter_wrapper_app PUBLIC flutter) 74 | target_include_directories(flutter_wrapper_app PUBLIC 75 | "${WRAPPER_ROOT}/include" 76 | ) 77 | add_dependencies(flutter_wrapper_app flutter_assemble) 78 | 79 | # === Flutter tool backend === 80 | # _phony_ is a non-existent file to force this command to run every time, 81 | # since currently there's no way to get a full input/output list from the 82 | # flutter tool. 83 | set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") 84 | set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) 85 | add_custom_command( 86 | OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} 87 | ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} 88 | ${CPP_WRAPPER_SOURCES_APP} 89 | ${PHONY_OUTPUT} 90 | COMMAND ${CMAKE_COMMAND} -E env 91 | ${FLUTTER_TOOL_ENVIRONMENT} 92 | "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" 93 | windows-x64 $ 94 | ) 95 | add_custom_target(flutter_assemble DEPENDS 96 | "${FLUTTER_LIBRARY}" 97 | ${FLUTTER_LIBRARY_HEADERS} 98 | ${CPP_WRAPPER_SOURCES_CORE} 99 | ${CPP_WRAPPER_SOURCES_PLUGIN} 100 | ${CPP_WRAPPER_SOURCES_APP} 101 | ) 102 | -------------------------------------------------------------------------------- /linux/my_application.cc: -------------------------------------------------------------------------------- 1 | #include "my_application.h" 2 | 3 | #include 4 | #ifdef GDK_WINDOWING_X11 5 | #include 6 | #endif 7 | 8 | #include "flutter/generated_plugin_registrant.h" 9 | 10 | struct _MyApplication { 11 | GtkApplication parent_instance; 12 | char** dart_entrypoint_arguments; 13 | }; 14 | 15 | G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) 16 | 17 | // Implements GApplication::activate. 18 | static void my_application_activate(GApplication* application) { 19 | MyApplication* self = MY_APPLICATION(application); 20 | GtkWindow* window = 21 | GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); 22 | 23 | // Use a header bar when running in GNOME as this is the common style used 24 | // by applications and is the setup most users will be using (e.g. Ubuntu 25 | // desktop). 26 | // If running on X and not using GNOME then just use a traditional title bar 27 | // in case the window manager does more exotic layout, e.g. tiling. 28 | // If running on Wayland assume the header bar will work (may need changing 29 | // if future cases occur). 30 | gboolean use_header_bar = TRUE; 31 | #ifdef GDK_WINDOWING_X11 32 | GdkScreen* screen = gtk_window_get_screen(window); 33 | if (GDK_IS_X11_SCREEN(screen)) { 34 | const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); 35 | if (g_strcmp0(wm_name, "GNOME Shell") != 0) { 36 | use_header_bar = FALSE; 37 | } 38 | } 39 | #endif 40 | if (use_header_bar) { 41 | GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); 42 | gtk_widget_show(GTK_WIDGET(header_bar)); 43 | gtk_header_bar_set_title(header_bar, "Tsacdop"); 44 | gtk_header_bar_set_show_close_button(header_bar, TRUE); 45 | gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); 46 | } else { 47 | gtk_window_set_title(window, "Tsacdop"); 48 | } 49 | 50 | gtk_window_set_default_size(window, 1280, 720); 51 | gtk_widget_show(GTK_WIDGET(window)); 52 | 53 | g_autoptr(FlDartProject) project = fl_dart_project_new(); 54 | fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); 55 | 56 | FlView* view = fl_view_new(project); 57 | gtk_widget_show(GTK_WIDGET(view)); 58 | gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); 59 | 60 | fl_register_plugins(FL_PLUGIN_REGISTRY(view)); 61 | 62 | gtk_widget_grab_focus(GTK_WIDGET(view)); 63 | } 64 | 65 | // Implements GApplication::local_command_line. 66 | static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { 67 | MyApplication* self = MY_APPLICATION(application); 68 | // Strip out the first argument as it is the binary name. 69 | self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); 70 | 71 | g_autoptr(GError) error = nullptr; 72 | if (!g_application_register(application, nullptr, &error)) { 73 | g_warning("Failed to register: %s", error->message); 74 | *exit_status = 1; 75 | return TRUE; 76 | } 77 | 78 | g_application_activate(application); 79 | *exit_status = 0; 80 | 81 | return TRUE; 82 | } 83 | 84 | // Implements GObject::dispose. 85 | static void my_application_dispose(GObject* object) { 86 | MyApplication* self = MY_APPLICATION(object); 87 | g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); 88 | G_OBJECT_CLASS(my_application_parent_class)->dispose(object); 89 | } 90 | 91 | static void my_application_class_init(MyApplicationClass* klass) { 92 | G_APPLICATION_CLASS(klass)->activate = my_application_activate; 93 | G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; 94 | G_OBJECT_CLASS(klass)->dispose = my_application_dispose; 95 | } 96 | 97 | static void my_application_init(MyApplication* self) {} 98 | 99 | MyApplication* my_application_new() { 100 | return MY_APPLICATION(g_object_new(my_application_get_type(), 101 | "application-id", APPLICATION_ID, 102 | "flags", G_APPLICATION_NON_UNIQUE, 103 | nullptr)); 104 | } 105 | -------------------------------------------------------------------------------- /linux/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required(VERSION 3.10) 2 | project(runner LANGUAGES CXX) 3 | 4 | set(BINARY_NAME "tsacdop_desktop") 5 | set(APPLICATION_ID "com.example.tsacdop_desktop") 6 | 7 | cmake_policy(SET CMP0063 NEW) 8 | 9 | set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") 10 | 11 | # Root filesystem for cross-building. 12 | if(FLUTTER_TARGET_PLATFORM_SYSROOT) 13 | set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) 14 | set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) 15 | set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) 16 | set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) 17 | set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) 18 | set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) 19 | endif() 20 | 21 | # Configure build options. 22 | if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) 23 | set(CMAKE_BUILD_TYPE "Debug" CACHE 24 | STRING "Flutter build mode" FORCE) 25 | set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS 26 | "Debug" "Profile" "Release") 27 | endif() 28 | 29 | # Compilation settings that should be applied to most targets. 30 | function(APPLY_STANDARD_SETTINGS TARGET) 31 | target_compile_features(${TARGET} PUBLIC cxx_std_14) 32 | target_compile_options(${TARGET} PRIVATE -Wall -Werror) 33 | target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") 34 | target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") 35 | endfunction() 36 | 37 | set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") 38 | 39 | # Flutter library and tool build rules. 40 | add_subdirectory(${FLUTTER_MANAGED_DIR}) 41 | 42 | # System-level dependencies. 43 | find_package(PkgConfig REQUIRED) 44 | pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) 45 | 46 | add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") 47 | 48 | # Application build 49 | add_executable(${BINARY_NAME} 50 | "main.cc" 51 | "my_application.cc" 52 | "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" 53 | ) 54 | apply_standard_settings(${BINARY_NAME}) 55 | target_link_libraries(${BINARY_NAME} PRIVATE flutter) 56 | target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) 57 | add_dependencies(${BINARY_NAME} flutter_assemble) 58 | # Only the install-generated bundle's copy of the executable will launch 59 | # correctly, since the resources must in the right relative locations. To avoid 60 | # people trying to run the unbundled copy, put it in a subdirectory instead of 61 | # the default top-level location. 62 | set_target_properties(${BINARY_NAME} 63 | PROPERTIES 64 | RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" 65 | ) 66 | 67 | # Generated plugin build rules, which manage building the plugins and adding 68 | # them to the application. 69 | include(flutter/generated_plugins.cmake) 70 | 71 | 72 | # === Installation === 73 | # By default, "installing" just makes a relocatable bundle in the build 74 | # directory. 75 | set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") 76 | if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) 77 | set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) 78 | endif() 79 | 80 | # Start with a clean build bundle directory every time. 81 | install(CODE " 82 | file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") 83 | " COMPONENT Runtime) 84 | 85 | set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") 86 | set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") 87 | 88 | install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" 89 | COMPONENT Runtime) 90 | 91 | install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" 92 | COMPONENT Runtime) 93 | 94 | install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" 95 | COMPONENT Runtime) 96 | 97 | if(PLUGIN_BUNDLED_LIBRARIES) 98 | install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" 99 | DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" 100 | COMPONENT Runtime) 101 | endif() 102 | 103 | # Fully re-copy the assets directory on each build to avoid having stale files 104 | # from a previous install. 105 | set(FLUTTER_ASSET_DIR_NAME "flutter_assets") 106 | install(CODE " 107 | file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") 108 | " COMPONENT Runtime) 109 | install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" 110 | DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) 111 | 112 | # Install the AOT library on non-Debug builds only. 113 | if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") 114 | install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" 115 | COMPONENT Runtime) 116 | endif() 117 | -------------------------------------------------------------------------------- /lib/providers/audio_state.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:io' show Platform; 3 | 4 | import 'package:flutter/material.dart'; 5 | import 'package:dart_vlc/dart_vlc.dart'; 6 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 7 | import 'package:desktop_notifications/desktop_notifications.dart'; 8 | 9 | import '../models/episodebrief.dart'; 10 | import '../storage/key_value_storage.dart'; 11 | import '../storage/sqflite_db.dart'; 12 | 13 | final audioState = ChangeNotifierProvider((ref) => AudioState(ref.read)); 14 | 15 | class AudioState extends ChangeNotifier { 16 | AudioState(this.read); 17 | 18 | @override 19 | void addListener(listener) { 20 | super.addListener(listener); 21 | _initQueue(); 22 | } 23 | 24 | @override 25 | void dispose() async { 26 | _audioPlayer?.dispose(); 27 | await _generalStateStream?.cancel(); 28 | await _playbackStateStream?.cancel(); 29 | await _postionStateStream?.cancel(); 30 | await _notifyClinet.close(); 31 | super.dispose(); 32 | } 33 | 34 | final _dbHelper = DBHelper(); 35 | final _playlistStorage = KeyValueStorage(playlistKey); 36 | final _notifyClinet = NotificationsClient(); 37 | final Reader read; 38 | 39 | Player? _audioPlayer; 40 | late Playlist _playlist; 41 | StreamSubscription? _generalStateStream; 42 | StreamSubscription? _playbackStateStream; 43 | StreamSubscription? _postionStateStream; 44 | 45 | Duration? _position = Duration.zero; 46 | 47 | Duration? get position => _position; 48 | 49 | Duration? _duration = Duration.zero; 50 | Duration? get duration => _duration; 51 | 52 | var _playerRunning = false; 53 | bool get playerRunning => _playerRunning; 54 | 55 | EpisodeBrief? _playingEpisode; 56 | EpisodeBrief? get playingEpisode => _playingEpisode; 57 | 58 | bool _playing = false; 59 | bool get playing => _playing; 60 | 61 | bool _buffering = false; 62 | bool get buffering => _buffering; 63 | 64 | List? _queue = []; 65 | 66 | List? get queue => _queue; 67 | 68 | bool get _haveNext => _queue!.isNotEmpty; 69 | 70 | double? _volume; 71 | double get volume => _volume ?? 1; 72 | 73 | var _noSlide = true; 74 | 75 | void loadEpisode(String url) async { 76 | final episodeNew = await _dbHelper.getRssItemWithUrl(url); 77 | if (_audioPlayer == null) { 78 | _audioPlayer = Player(id: 69420); 79 | _playerRunning = true; 80 | notifyListeners(); 81 | } 82 | _audioPlayer?.stop(); 83 | _playlist = Playlist(medias: [Media.network(episodeNew!.enclosureUrl)]); 84 | _audioPlayer!.open(_playlist); 85 | _generalStateStream = _audioPlayer!.generalStream.listen((event) { 86 | _volume = event.volume; 87 | notifyListeners(); 88 | }); 89 | _playbackStateStream = _audioPlayer!.playbackStream.listen((event) { 90 | if (event.isCompleted) { 91 | stop(); 92 | } 93 | print(event.toString()); 94 | _playing = event.isPlaying; 95 | _buffering = !event.isSeekable; 96 | notifyListeners(); 97 | }); 98 | _postionStateStream = _audioPlayer!.positionStream.listen((event) { 99 | _duration = event.duration; 100 | if (_noSlide) _position = event.position; 101 | notifyListeners(); 102 | }); 103 | _playingEpisode = episodeNew; 104 | _notifyEpisode(_playingEpisode); 105 | notifyListeners(); 106 | _audioPlayer!.play(); 107 | } 108 | 109 | void loadPlaylist() { 110 | final url = _queue!.first; 111 | loadEpisode(url); 112 | } 113 | 114 | void pauseAduio() async { 115 | _audioPlayer!.pause(); 116 | } 117 | 118 | void play() { 119 | _audioPlayer!.play(); 120 | } 121 | 122 | Future slideSeek(double value, {bool end = false}) async { 123 | _noSlide = false; 124 | var seekValue = _duration! * value; 125 | _position = seekValue; 126 | notifyListeners(); 127 | if (end) { 128 | _audioPlayer!.seek(seekValue); 129 | _noSlide = true; 130 | } 131 | } 132 | 133 | void setVolume(double value) { 134 | _audioPlayer!.setVolume(value); 135 | } 136 | 137 | void playNext() { 138 | if (_haveNext) { 139 | _queue!.remove(_playingEpisode!.enclosureUrl); 140 | loadEpisode(_queue!.first); 141 | _saveQueue(); 142 | } else { 143 | stop(); 144 | } 145 | } 146 | 147 | Future _seekRelative(Duration duration) async { 148 | var seekPosition = _position! + duration; 149 | if (seekPosition < Duration.zero) seekPosition = Duration.zero; 150 | _audioPlayer!.seek(seekPosition); 151 | } 152 | 153 | Future fastForward(Duration duration) async { 154 | await _seekRelative(duration); 155 | } 156 | 157 | Future rewind(Duration duration) async { 158 | await _seekRelative(-duration); 159 | } 160 | 161 | void stop() { 162 | _audioPlayer?.stop(); 163 | _playerRunning = false; 164 | _audioPlayer = null; 165 | notifyListeners(); 166 | } 167 | 168 | Future _initQueue() async { 169 | _queue = await _playlistStorage.getStringList(); 170 | notifyListeners(); 171 | } 172 | 173 | Future _saveQueue() async { 174 | notifyListeners(); 175 | await _playlistStorage.saveStringList(_queue!); 176 | } 177 | 178 | void addToPlaylist(String url) async { 179 | if (!_queue!.contains(url)) { 180 | _queue = [..._queue!, url]; 181 | _saveQueue(); 182 | } 183 | } 184 | 185 | void removeFromPlaylist(String url) { 186 | _queue = _queue!.where((e) => e != url).toList(); 187 | _saveQueue(); 188 | } 189 | 190 | void _notifyEpisode(EpisodeBrief? episode) { 191 | if (Platform.isLinux) { 192 | _notifyClinet.notify(episode!.title!, 193 | appName: 'Tsacdop', appIcon: 'media-playback-start'); 194 | } 195 | } 196 | } 197 | 198 | class CurrentPlaybackState { 199 | final Duration? position; 200 | final Duration? audioDuration; 201 | final EpisodeBrief episode; 202 | 203 | CurrentPlaybackState(this.episode, {this.position, this.audioDuration}); 204 | } 205 | -------------------------------------------------------------------------------- /lib/providers/downloader.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | import 'dart:async'; 3 | 4 | import 'package:equatable/equatable.dart'; 5 | import 'package:dio/dio.dart'; 6 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 7 | import 'package:path_provider/path_provider.dart'; 8 | import 'package:path/path.dart' as path; 9 | import 'package:tsacdop_desktop/storage/sqflite_db.dart'; 10 | import 'package:uuid/uuid.dart'; 11 | import '../models/episodebrief.dart'; 12 | 13 | enum DownloadTaskStatus { 14 | undefined, 15 | enqueued, 16 | running, 17 | complete, 18 | failed, 19 | canceled, 20 | paused 21 | } 22 | 23 | final downloadNotification = StateProvider((ref) => null); 24 | 25 | final downloadProvider = StateNotifierProvider>( 26 | (ref) => Downloader(ref.read)); 27 | 28 | class Downloader extends StateNotifier> { 29 | Downloader(this.read) : super([]); 30 | 31 | final Reader read; 32 | 33 | final _dio = Dio(BaseOptions( 34 | connectTimeout: 30000, 35 | )); 36 | 37 | final _pathInvilid = RegExp(r'\/|\\|\?|\*|\.'); 38 | var _dbHelper = DBHelper(); 39 | 40 | int indexOf(EpisodeBrief episode) { 41 | for (var task in state) { 42 | if (task.episode == episode) return state.indexOf(task); 43 | } 44 | return -1; 45 | } 46 | 47 | void _updateTask(DownloadTask downloadTask) { 48 | state = [ 49 | for (var task in state) 50 | if (task.taskId == downloadTask.taskId) downloadTask else task 51 | ]; 52 | } 53 | 54 | Future download(EpisodeBrief episode) async { 55 | final dir = await (getDownloadsDirectory() as FutureOr); 56 | var localPath = path.join(dir.path, 'Tsacdop'); 57 | final saveDir = Directory(localPath); 58 | var hasExisted = await saveDir.exists(); 59 | if (!hasExisted) { 60 | saveDir.create(); 61 | } 62 | var feedTile = 63 | episode.feedTitle!.replaceAll(' ', '_').replaceAll(_pathInvilid, ''); 64 | var savePath = path.join(localPath, Uri.encodeComponent(feedTile)); 65 | final podcastDir = Directory(savePath); 66 | var dirExisted = await podcastDir.exists(); 67 | if (!dirExisted) { 68 | podcastDir.create(); 69 | } 70 | var now = DateTime.now(); 71 | var datePlus = now.year.toString() + 72 | now.month.toString() + 73 | now.day.toString() + 74 | now.second.toString(); 75 | var title = 76 | episode.title!.replaceAll(' ', '_').replaceAll(_pathInvilid, ''); 77 | var fileName = 78 | '$title$datePlus.${episode.enclosureUrl.split('/').last.split('.').last}'; 79 | fileName = Uri.encodeComponent(fileName); 80 | if (fileName.length > 50) { 81 | fileName = fileName.substring(fileName.length - 50); 82 | } 83 | var filePath = path.join(savePath, fileName); 84 | var cancelToken; 85 | var downloadTask = DownloadTask(episode, 86 | filename: fileName, 87 | savedDir: savePath, 88 | timeCreated: now.millisecondsSinceEpoch, 89 | status: DownloadTaskStatus.enqueued, 90 | cancelToken: cancelToken); 91 | 92 | state = [...state, downloadTask]; 93 | var response = await _dio.download(episode.enclosureUrl, filePath, 94 | cancelToken: cancelToken, onReceiveProgress: (count, total) { 95 | if (total > 0 && count > 0) { 96 | var progress = (count * 100) ~/ total; 97 | _updateTask(downloadTask.copyWith( 98 | progress: progress, status: DownloadTaskStatus.running)); 99 | if (read(downloadNotification.notifier).state == null || 100 | read(downloadNotification.notifier).state!.contains(episode.title!)) 101 | read(downloadNotification.notifier).state = 102 | 'Downloading ${episode.title} $progress%'; 103 | } 104 | }, deleteOnError: true); 105 | if (response.statusCode == 200) { 106 | _updateTask(downloadTask.copyWith( 107 | progress: 100, status: DownloadTaskStatus.complete)); 108 | 109 | read(downloadNotification.notifier).state = null; 110 | var fileStat = await File(filePath).stat(); 111 | _dbHelper.saveMediaId( 112 | episode.enclosureUrl, filePath, downloadTask.taskId, fileStat.size); 113 | } else { 114 | _updateTask(downloadTask.copyWith(status: DownloadTaskStatus.failed)); 115 | read(downloadNotification.notifier).state = null; 116 | } 117 | } 118 | 119 | void cancelDownload(DownloadTask task) { 120 | var cancelToken = task.cancelToken; 121 | cancelToken?.cancel(); 122 | state = state.where((t) => t != task).toList(); 123 | } 124 | 125 | Future deleteDownload(EpisodeBrief episode) async { 126 | final episodeNew = await (_dbHelper.getRssItemWithUrl(episode.enclosureUrl) 127 | as FutureOr); 128 | var file = File(episodeNew.mediaId!); 129 | if (file.existsSync()) { 130 | await file.delete(); 131 | } 132 | await _dbHelper.delDownloaded(episode.enclosureUrl); 133 | state = state.where((task) => task.episode != episode).toList(); 134 | } 135 | } 136 | 137 | class DownloadTask extends Equatable { 138 | final EpisodeBrief episode; 139 | final String taskId; 140 | final String? filename; 141 | final String? savedDir; 142 | final int? timeCreated; 143 | final int? progress; 144 | final DownloadTaskStatus? status; 145 | final CancelToken? cancelToken; 146 | DownloadTask(this.episode, 147 | {String? taskId, 148 | this.filename, 149 | this.savedDir, 150 | this.timeCreated, 151 | this.progress = 0, 152 | this.status = DownloadTaskStatus.undefined, 153 | this.cancelToken}) 154 | : taskId = taskId ?? Uuid().v4(); 155 | 156 | DownloadTask copyWith({int? progress, DownloadTaskStatus? status}) { 157 | return DownloadTask(episode, 158 | filename: filename, 159 | savedDir: savedDir, 160 | timeCreated: timeCreated, 161 | taskId: taskId, 162 | progress: progress, 163 | status: status); 164 | } 165 | 166 | @override 167 | List get props => [taskId, episode.enclosureUrl]; 168 | } 169 | -------------------------------------------------------------------------------- /lib/widgets/custom_paint.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math' as math; 2 | import 'package:flutter/material.dart'; 3 | 4 | class LayoutPainter extends CustomPainter { 5 | double scale; 6 | Color? color; 7 | LayoutPainter(this.scale, this.color); 8 | @override 9 | void paint(Canvas canvas, Size size) { 10 | var _paint = Paint() 11 | ..color = color! 12 | ..strokeWidth = 1.0 13 | ..style = PaintingStyle.stroke 14 | ..strokeCap = StrokeCap.round; 15 | 16 | canvas.drawRect(Rect.fromLTRB(0, 0, 10 + 5 * scale, 10), _paint); 17 | if (scale < 4) { 18 | canvas.drawRect( 19 | Rect.fromLTRB(10 + 5 * scale, 0, 20 + 10 * scale, 10), _paint); 20 | canvas.drawRect( 21 | Rect.fromLTRB(20 + 5 * scale, 0, 30, 10 - 10 * scale), _paint); 22 | } 23 | } 24 | 25 | @override 26 | bool shouldRepaint(LayoutPainter oldDelegate) { 27 | return oldDelegate.scale != scale || oldDelegate.color != color; 28 | } 29 | } 30 | 31 | /// Multi select button. 32 | class MultiSelectPainter extends CustomPainter { 33 | Color color; 34 | MultiSelectPainter({required this.color}); 35 | @override 36 | void paint(Canvas canvas, Size size) { 37 | var paint = Paint() 38 | ..color = color 39 | ..strokeWidth = 1.0 40 | ..style = PaintingStyle.fill 41 | ..strokeCap = StrokeCap.round; 42 | final x = size.width / 2; 43 | final y = size.height / 2; 44 | var path = Path(); 45 | path.moveTo(0, 0); 46 | path.lineTo(x, 0); 47 | path.lineTo(x, y * 2); 48 | path.lineTo(x * 2, y * 2); 49 | path.lineTo(x * 2, y); 50 | path.lineTo(0, y); 51 | path.lineTo(0, 0); 52 | path.close(); 53 | canvas.drawPath(path, paint); 54 | } 55 | 56 | @override 57 | bool shouldRepaint(MultiSelectPainter oldDelegate) { 58 | return false; 59 | } 60 | } 61 | 62 | /// Hide listened painter. 63 | class HideListenedPainter extends CustomPainter { 64 | Color? color; 65 | Color? backgroundColor; 66 | double? fraction; 67 | double stroke; 68 | HideListenedPainter( 69 | {this.color, this.stroke = 1.0, this.backgroundColor, this.fraction}); 70 | @override 71 | void paint(Canvas canvas, Size size) { 72 | var _paint = Paint() 73 | ..color = color! 74 | ..strokeWidth = stroke 75 | ..strokeCap = StrokeCap.round 76 | ..style = PaintingStyle.stroke; 77 | var _linePaint = Paint() 78 | ..color = backgroundColor! 79 | ..strokeWidth = stroke * 2 80 | ..strokeCap = StrokeCap.round 81 | ..style = PaintingStyle.stroke; 82 | var _path = Path(); 83 | 84 | _path.moveTo(size.width / 6, size.height * 3 / 8); 85 | _path.lineTo(size.width / 6, size.height * 5 / 8); 86 | _path.moveTo(size.width / 3, size.height / 4); 87 | _path.lineTo(size.width / 3, size.height * 3 / 4); 88 | _path.moveTo(size.width / 2, size.height / 8); 89 | _path.lineTo(size.width / 2, size.height * 7 / 8); 90 | _path.moveTo(size.width * 5 / 6, size.height * 3 / 8); 91 | _path.lineTo(size.width * 5 / 6, size.height * 5 / 8); 92 | _path.moveTo(size.width * 2 / 3, size.height / 4); 93 | _path.lineTo(size.width * 2 / 3, size.height * 3 / 4); 94 | 95 | canvas.drawPath(_path, _paint); 96 | if (fraction! > 0) { 97 | canvas.drawLine( 98 | Offset(size.width, size.height) / 5, 99 | Offset(size.width, size.height) / 5 + 100 | Offset(size.width, size.height) * 3 / 5 * fraction!, 101 | _linePaint); 102 | } 103 | } 104 | 105 | @override 106 | bool shouldRepaint(HideListenedPainter oldDelegate) { 107 | return oldDelegate.fraction != fraction; 108 | } 109 | } 110 | 111 | ///Download button. 112 | class DownloadPainter extends CustomPainter { 113 | double? fraction; 114 | Color? color; 115 | Color? progressColor; 116 | double? progress; 117 | double pauseProgress; 118 | double stroke; 119 | DownloadPainter( 120 | {this.fraction, 121 | this.color, 122 | this.progressColor, 123 | this.progress = 0, 124 | this.stroke = 2, 125 | this.pauseProgress = 0}); 126 | 127 | @override 128 | void paint(Canvas canvas, Size size) { 129 | var _paint = Paint() 130 | ..color = color! 131 | ..strokeWidth = stroke 132 | ..strokeCap = StrokeCap.round; 133 | var _circlePaint = Paint() 134 | ..color = color!.withAlpha(70) 135 | ..style = PaintingStyle.stroke 136 | ..strokeWidth = stroke; 137 | var _progressPaint = Paint() 138 | ..color = progressColor! 139 | ..strokeCap = StrokeCap.round 140 | ..style = PaintingStyle.stroke 141 | ..strokeWidth = stroke; 142 | var width = size.width; 143 | var height = size.height; 144 | var center = Offset(size.width / 2, size.height / 2); 145 | if (pauseProgress == 0 && progress! < 1) { 146 | canvas.drawLine( 147 | Offset(width / 2, 4), Offset(width / 2, height * 4 / 5), _paint); 148 | canvas.drawLine(Offset(width / 4, height / 2), 149 | Offset(width / 2, height * 4 / 5), _paint); 150 | canvas.drawLine(Offset(width * 3 / 4, height / 2), 151 | Offset(width / 2, height * 4 / 5), _paint); 152 | } 153 | 154 | if (fraction == 0) { 155 | canvas.drawLine( 156 | Offset(width / 5, height), Offset(width * 4 / 5, height), _paint); 157 | } else if (progress! < 1) { 158 | canvas.drawArc(Rect.fromCircle(center: center, radius: width / 2), 159 | math.pi / 2, math.pi * fraction!, false, _circlePaint); 160 | canvas.drawArc(Rect.fromCircle(center: center, radius: width / 2), 161 | math.pi / 2, -math.pi * fraction!, false, _circlePaint); 162 | } 163 | 164 | if (progress == 1) { 165 | canvas.drawLine(Offset(width / 5, height * 9 / 10), 166 | Offset(width * 4 / 5, height * 9 / 10), _progressPaint); 167 | canvas.drawLine(Offset(width / 5, height * 5 / 10), 168 | Offset(width * 2 / 5, height * 7 / 10), _progressPaint); 169 | canvas.drawLine(Offset(width * 4 / 5, height * 3 / 10), 170 | Offset(width * 2 / 5, height * 7 / 10), _progressPaint); 171 | } 172 | 173 | if (fraction == 1 && progress! < 1) { 174 | canvas.drawArc(Rect.fromCircle(center: center, radius: width / 2), 175 | -math.pi / 2, math.pi * 2 * progress!, false, _progressPaint); 176 | } 177 | 178 | if (pauseProgress > 0) { 179 | canvas.drawLine( 180 | Offset(width / 5 + height * 3 * pauseProgress / 20, 181 | height / 2 - height * pauseProgress / 5), 182 | Offset(width / 2 - height * 3 * pauseProgress / 20, 183 | height * 4 / 5 - height * pauseProgress / 10), 184 | _paint); 185 | canvas.drawLine( 186 | Offset(width * 4 / 5 - height * 3 * pauseProgress / 20, 187 | height / 2 - height * pauseProgress / 5), 188 | Offset(width / 2 + height * 3 * pauseProgress / 20, 189 | height * 4 / 5 - height * pauseProgress / 10), 190 | _paint); 191 | } 192 | } 193 | 194 | @override 195 | bool shouldRepaint(DownloadPainter oldDelegate) { 196 | return oldDelegate.fraction != fraction || 197 | oldDelegate.progress != progress || 198 | oldDelegate.pauseProgress != pauseProgress; 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /lib/providers/settings_state.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 5 | import 'package:intl/intl.dart'; 6 | import 'package:intl/intl_standalone.dart'; 7 | 8 | import '../generated/l10n.dart'; 9 | import '../storage/key_value_storage.dart'; 10 | 11 | final settingsState = SettingsState(); 12 | final settings = ChangeNotifierProvider((ref) => settingsState); 13 | 14 | class SettingsState extends ChangeNotifier { 15 | final _themeStorage = KeyValueStorage(themesKey); 16 | final _accentStorage = KeyValueStorage(accentColorKey); 17 | final _realDarkStorage = KeyValueStorage(realDarkKey); 18 | final _proxyStorage = KeyValueStorage(proxyKey); 19 | 20 | Future initTheme() async { 21 | await _getTheme(); 22 | await _getAccentSetColor(); 23 | await _getRealDark(); 24 | await _getProxy(); 25 | await _getLocale(); 26 | } 27 | 28 | Color? _accentSetColor; 29 | ThemeMode? _themeMode; 30 | bool? _realDark; 31 | String? _proxy; 32 | late Locale _locale; 33 | String? get proxy => _proxy; 34 | 35 | ThemeMode? get themeMode => _themeMode; 36 | 37 | ThemeData get lightTheme => ThemeData( 38 | colorScheme: ColorScheme.light( 39 | primary: Colors.grey[100]!, 40 | secondary: _accentSetColor!, 41 | ), 42 | splashColor: Colors.transparent, 43 | primaryColor: Colors.grey[100], 44 | splashFactory: NoSplash.splashFactory, 45 | primaryColorLight: Colors.white, 46 | primaryColorDark: Colors.grey[300], 47 | dialogBackgroundColor: Colors.white, 48 | backgroundColor: Colors.grey[100], 49 | appBarTheme: AppBarTheme( 50 | color: Colors.grey[100], 51 | elevation: 0, 52 | ), 53 | textTheme: TextTheme( 54 | bodyText2: TextStyle(fontSize: 18.0, fontWeight: FontWeight.w500), 55 | bodyText1: TextStyle( 56 | fontSize: 18.0, fontWeight: FontWeight.w500, color: Colors.black), 57 | subtitle1: TextStyle(fontSize: 16.0, fontWeight: FontWeight.normal), 58 | subtitle2: TextStyle(fontSize: 16.0, fontWeight: FontWeight.normal), 59 | ), 60 | tabBarTheme: TabBarTheme( 61 | labelColor: Colors.black, 62 | unselectedLabelColor: Colors.grey[400], 63 | ), 64 | textSelectionTheme: TextSelectionThemeData( 65 | cursorColor: _accentSetColor, 66 | selectionColor: _accentSetColor, 67 | ), 68 | toggleableActiveColor: _accentSetColor, 69 | elevatedButtonTheme: ElevatedButtonThemeData( 70 | style: ElevatedButton.styleFrom( 71 | elevation: 0, 72 | splashFactory: NoSplash.splashFactory, 73 | shadowColor: Colors.transparent, 74 | onSurface: Colors.transparent, 75 | primary: _accentSetColor, 76 | shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), 77 | padding: EdgeInsets.zero, 78 | minimumSize: Size(100, 40), 79 | ), 80 | ), 81 | buttonTheme: ButtonThemeData( 82 | height: 32, 83 | hoverColor: _accentSetColor!.withAlpha(70), 84 | splashColor: _accentSetColor!.withAlpha(70))); 85 | 86 | ThemeData get darkTheme => ThemeData.dark().copyWith( 87 | colorScheme: ColorScheme.dark( 88 | secondary: _accentSetColor!, 89 | primary: Colors.grey[100]!, 90 | ), 91 | splashFactory: NoSplash.splashFactory, 92 | primaryColorDark: Colors.grey[800], 93 | scaffoldBackgroundColor: 94 | _realDark! ? Colors.black87 : Color(0XFF212121), 95 | primaryColor: _realDark! ? Colors.black : Color(0XFF1B1B1B), 96 | popupMenuTheme: PopupMenuThemeData() 97 | .copyWith(color: _realDark! ? Colors.grey[900] : null), 98 | appBarTheme: AppBarTheme(elevation: 0), 99 | buttonTheme: ButtonThemeData(height: 32), 100 | dialogBackgroundColor: _realDark! ? Colors.grey[900] : null, 101 | textTheme: TextTheme( 102 | bodyText2: TextStyle(fontSize: 18.0, fontWeight: FontWeight.w500), 103 | bodyText1: TextStyle( 104 | fontSize: 18.0, fontWeight: FontWeight.w500, color: Colors.white), 105 | subtitle1: TextStyle(fontSize: 16.0, fontWeight: FontWeight.normal), 106 | subtitle2: TextStyle(fontSize: 16.0, fontWeight: FontWeight.normal), 107 | ), 108 | elevatedButtonTheme: ElevatedButtonThemeData( 109 | style: ElevatedButton.styleFrom( 110 | elevation: 0, 111 | splashFactory: NoSplash.splashFactory, 112 | shadowColor: Colors.transparent, 113 | onSurface: Colors.transparent, 114 | primary: _accentSetColor, 115 | shape: 116 | RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), 117 | padding: EdgeInsets.zero, 118 | minimumSize: Size(100, 40), 119 | ), 120 | ), 121 | ); 122 | 123 | set setAccentColor(Color color) { 124 | _accentSetColor = color; 125 | _saveAccentSetColor(); 126 | notifyListeners(); 127 | } 128 | 129 | set setRealDark(bool value) { 130 | _realDark = value; 131 | _setRealDark(); 132 | notifyListeners(); 133 | } 134 | 135 | set setTheme(ThemeMode? mode) { 136 | _themeMode = mode; 137 | _saveTheme(); 138 | notifyListeners(); 139 | } 140 | 141 | set setProxy(String? proxy) { 142 | _proxy = proxy; 143 | _saveProxy(); 144 | notifyListeners(); 145 | } 146 | 147 | Future _getTheme() async { 148 | var mode = await _themeStorage.getInt(); 149 | _themeMode = ThemeMode.values[mode]; 150 | } 151 | 152 | Future _getAccentSetColor() async { 153 | var colorString = await _accentStorage.getString(); 154 | if (colorString!.isNotEmpty) { 155 | var color = int.parse('FF${colorString.toUpperCase()}', radix: 16); 156 | _accentSetColor = Color(color).withOpacity(1.0); 157 | } else { 158 | _accentSetColor = Colors.teal[500]; 159 | await _saveAccentSetColor(); 160 | } 161 | } 162 | 163 | Future _getRealDark() async { 164 | _realDark = await _realDarkStorage.getBool(defaultValue: false); 165 | } 166 | 167 | Future _getProxy() async { 168 | _proxy = await _proxyStorage.getString(); 169 | } 170 | 171 | Future _getLocale() async { 172 | var localeString = await KeyValueStorage(localeKey).getStringList(); 173 | if (localeString.isEmpty) { 174 | await findSystemLocale(); 175 | var systemLanCode; 176 | final list = Intl.systemLocale.split('_'); 177 | if (list.length == 2) { 178 | systemLanCode = list.first; 179 | } else if (list.length == 3) { 180 | systemLanCode = '${list[0]}_${list[1]}'; 181 | } else { 182 | systemLanCode = 'en'; 183 | } 184 | _locale = Locale(systemLanCode); 185 | } else { 186 | _locale = Locale(localeString.first, localeString[1]); 187 | } 188 | await S.load(_locale); 189 | } 190 | 191 | Future _saveAccentSetColor() async { 192 | await _accentStorage 193 | .saveString(_accentSetColor.toString().substring(10, 16)); 194 | } 195 | 196 | Future _saveTheme() async { 197 | await _themeStorage.saveInt(_themeMode!.index); 198 | } 199 | 200 | Future _setRealDark() async { 201 | await _realDarkStorage.saveBool(_realDark); 202 | } 203 | 204 | Future _saveProxy() async { 205 | await _proxyStorage.saveString(_proxy!); 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /lib/screens/episode_detail.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_html/flutter_html.dart'; 3 | import 'package:linkify/linkify.dart'; 4 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 5 | import 'package:intl/intl.dart'; 6 | import 'package:tsacdop_desktop/providers/audio_state.dart'; 7 | 8 | import 'podcasts_page.dart'; 9 | import '../widgets/episode_menu.dart'; 10 | import '../storage/sqflite_db.dart'; 11 | import '../models/episodebrief.dart'; 12 | import '../utils/extension_helper.dart'; 13 | 14 | class EpisodeDetail extends ConsumerWidget { 15 | final EpisodeBrief episode; 16 | const EpisodeDetail(this.episode, {Key? key}) : super(key: key); 17 | 18 | @override 19 | Widget build(BuildContext context, WidgetRef ref) { 20 | final s = context.s!; 21 | return Container( 22 | color: context.primaryColor, 23 | child: Column( 24 | children: [ 25 | SizedBox( 26 | height: 30, 27 | child: Align( 28 | alignment: Alignment.topLeft, 29 | child: Material( 30 | color: Colors.transparent, 31 | child: InkWell( 32 | onTap: () => ref.watch(openEpisode.notifier).state = null, 33 | hoverColor: context.accentColor, 34 | child: Container( 35 | height: 30, 36 | width: 30, 37 | child: Icon(Icons.keyboard_arrow_left), 38 | ), 39 | ), 40 | ), 41 | ), 42 | ), 43 | Expanded( 44 | child: ListView( 45 | children: [ 46 | Padding( 47 | padding: EdgeInsets.symmetric(horizontal: 20, vertical: 10), 48 | child: 49 | Text(episode.title!, style: context.textTheme.headline5), 50 | ), 51 | Padding( 52 | padding: EdgeInsets.symmetric(horizontal: 20, vertical: 10), 53 | child: Row( 54 | children: [ 55 | Text( 56 | s.published(DateFormat.yMMMd().format( 57 | DateTime.fromMillisecondsSinceEpoch( 58 | episode.pubDate!))), 59 | style: TextStyle(color: context.accentColor), 60 | ), 61 | SizedBox(width: 10), 62 | if (episode.explicit == 1) 63 | Text('E', 64 | style: TextStyle( 65 | fontWeight: FontWeight.bold, color: Colors.red)) 66 | ], 67 | ), 68 | ), 69 | _ShowNote(episode) 70 | ], 71 | ), 72 | ), 73 | Container( 74 | height: 60, 75 | width: double.infinity, 76 | color: context.scaffoldBackgroundColor, 77 | child: Material( 78 | child: Row( 79 | children: [ 80 | SizedBox(width: 10), 81 | FavIcon(episode), 82 | DownloadIcon(episode), 83 | PlaylistButton(episode), 84 | Spacer(), 85 | ElevatedButton( 86 | child: Text(s.play), 87 | onPressed: () { 88 | ref.read(audioState).loadEpisode(episode.enclosureUrl); 89 | }, 90 | ), 91 | SizedBox(width: 10), 92 | // PlayButton(episode) 93 | ], 94 | ), 95 | ), 96 | ) 97 | ], 98 | ), 99 | ); 100 | } 101 | } 102 | 103 | class _ShowNote extends StatelessWidget { 104 | final EpisodeBrief episode; 105 | const _ShowNote(this.episode, {Key? key}) : super(key: key); 106 | 107 | int? _getTimeStamp(String url) { 108 | final time = url.substring(3).trim(); 109 | final data = time.split(':'); 110 | var seconds; 111 | if (data.length == 3) { 112 | seconds = int.tryParse(data[0])! * 3600 + 113 | int.tryParse(data[1])! * 60 + 114 | int.tryParse(data[2])!; 115 | } else if (data.length == 2) { 116 | seconds = int.tryParse(data[0])! * 60 + int.tryParse(data[1])!; 117 | } 118 | return seconds; 119 | } 120 | 121 | Future _getSDescription(String url) async { 122 | var description; 123 | var dbHelper = DBHelper(); 124 | description = (await dbHelper.getDescription(url))! 125 | .replaceAll(RegExp(r'\s?

(
)?

\s?'), '') 126 | .replaceAll('\r', '') 127 | .trim(); 128 | if (!description.contains('<')) { 129 | final linkList = linkify(description, 130 | options: LinkifyOptions(humanize: false), 131 | linkifiers: [UrlLinkifier(), EmailLinkifier()]); 132 | for (var element in linkList) { 133 | if (element is UrlElement) { 134 | description = description.replaceAll(element.url, 135 | '${element.text}'); 136 | } 137 | if (element is EmailElement) { 138 | final address = element.emailAddress; 139 | description = description.replaceAll(address, 140 | '$address'); 141 | } 142 | } 143 | await dbHelper.saveEpisodeDes(url, description: description); 144 | } 145 | return description; 146 | } 147 | 148 | @override 149 | Widget build(BuildContext context) { 150 | final s = context.s; 151 | return FutureBuilder( 152 | future: _getSDescription(episode.enclosureUrl), 153 | builder: (context, snapshot) { 154 | if (snapshot.hasData) { 155 | var description = snapshot.data; 156 | return description != null 157 | ? SingleChildScrollView( 158 | child: Padding( 159 | padding: const EdgeInsets.symmetric(horizontal: 20), 160 | child: SelectableHtml( 161 | data: description, 162 | style: { 163 | 'a': Style(color: context.accentColor), 164 | 'p': Style(lineHeight: LineHeight.rem(1.5)), 165 | 'h2': Style(lineHeight: LineHeight.rem(2)), 166 | 'h3': Style(lineHeight: LineHeight.rem(2)), 167 | }, 168 | onLinkTap: (url, _, __, ___) { 169 | url!.launchUrl; 170 | }, 171 | ), 172 | ), 173 | ) 174 | : Container( 175 | height: context.width, 176 | alignment: Alignment.center, 177 | child: Column( 178 | mainAxisAlignment: MainAxisAlignment.center, 179 | children: [ 180 | Image( 181 | image: AssetImage('assets/shownote.png'), 182 | height: 100.0, 183 | ), 184 | Padding(padding: EdgeInsets.all(5.0)), 185 | Text(s!.noShownote, 186 | textAlign: TextAlign.center, 187 | style: TextStyle( 188 | color: context.textColor!.withOpacity(0.5))), 189 | ], 190 | ), 191 | ); 192 | } else { 193 | return Center(); 194 | } 195 | }, 196 | ); 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /windows/runner/win32_window.cpp: -------------------------------------------------------------------------------- 1 | #include "win32_window.h" 2 | 3 | #include 4 | 5 | #include "resource.h" 6 | 7 | namespace 8 | { 9 | 10 | constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; 11 | 12 | // The number of Win32Window objects that currently exist. 13 | static int g_active_window_count = 0; 14 | 15 | using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); 16 | 17 | // Scale helper to convert logical scaler values to physical using passed in 18 | // scale factor 19 | int Scale(int source, double scale_factor) 20 | { 21 | return static_cast(source * scale_factor); 22 | } 23 | 24 | // Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. 25 | // This API is only needed for PerMonitor V1 awareness mode. 26 | void EnableFullDpiSupportIfAvailable(HWND hwnd) 27 | { 28 | HMODULE user32_module = LoadLibraryA("User32.dll"); 29 | if (!user32_module) 30 | { 31 | return; 32 | } 33 | auto enable_non_client_dpi_scaling = 34 | reinterpret_cast( 35 | GetProcAddress(user32_module, "EnableNonClientDpiScaling")); 36 | if (enable_non_client_dpi_scaling != nullptr) 37 | { 38 | enable_non_client_dpi_scaling(hwnd); 39 | FreeLibrary(user32_module); 40 | } 41 | } 42 | 43 | } // namespace 44 | 45 | // Manages the Win32Window's window class registration. 46 | class WindowClassRegistrar 47 | { 48 | public: 49 | ~WindowClassRegistrar() = default; 50 | 51 | // Returns the singleton registar instance. 52 | static WindowClassRegistrar *GetInstance() 53 | { 54 | if (!instance_) 55 | { 56 | instance_ = new WindowClassRegistrar(); 57 | } 58 | return instance_; 59 | } 60 | 61 | // Returns the name of the window class, registering the class if it hasn't 62 | // previously been registered. 63 | const wchar_t *GetWindowClass(); 64 | 65 | // Unregisters the window class. Should only be called if there are no 66 | // instances of the window. 67 | void UnregisterWindowClass(); 68 | 69 | private: 70 | WindowClassRegistrar() = default; 71 | 72 | static WindowClassRegistrar *instance_; 73 | 74 | bool class_registered_ = false; 75 | }; 76 | 77 | WindowClassRegistrar *WindowClassRegistrar::instance_ = nullptr; 78 | 79 | const wchar_t *WindowClassRegistrar::GetWindowClass() 80 | { 81 | if (!class_registered_) 82 | { 83 | WNDCLASS window_class{}; 84 | window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); 85 | window_class.lpszClassName = kWindowClassName; 86 | window_class.style = CS_HREDRAW | CS_VREDRAW; 87 | window_class.cbClsExtra = 0; 88 | window_class.cbWndExtra = 0; 89 | window_class.hInstance = GetModuleHandle(nullptr); 90 | window_class.hIcon = 91 | LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); 92 | window_class.hbrBackground = 0; 93 | window_class.lpszMenuName = nullptr; 94 | window_class.lpfnWndProc = Win32Window::WndProc; 95 | RegisterClass(&window_class); 96 | class_registered_ = true; 97 | } 98 | return kWindowClassName; 99 | } 100 | 101 | void WindowClassRegistrar::UnregisterWindowClass() 102 | { 103 | UnregisterClass(kWindowClassName, nullptr); 104 | class_registered_ = false; 105 | } 106 | 107 | Win32Window::Win32Window() 108 | { 109 | ++g_active_window_count; 110 | } 111 | 112 | Win32Window::~Win32Window() 113 | { 114 | --g_active_window_count; 115 | Destroy(); 116 | } 117 | 118 | bool Win32Window::CreateAndShow(const std::wstring &title, 119 | const Point &origin, 120 | const Size &size) 121 | { 122 | Destroy(); 123 | 124 | const wchar_t *window_class = 125 | WindowClassRegistrar::GetInstance()->GetWindowClass(); 126 | 127 | const POINT target_point = {static_cast(origin.x), 128 | static_cast(origin.y)}; 129 | HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); 130 | UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); 131 | double scale_factor = dpi / 96.0; 132 | 133 | HWND window = CreateWindow( 134 | window_class, title.c_str(), WS_OVERLAPPEDWINDOW | WS_VISIBLE | WS_EX_DLGMODALFRAME, 135 | Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), 136 | Scale(size.width, scale_factor), Scale(size.height, scale_factor), 137 | nullptr, nullptr, GetModuleHandle(nullptr), this); 138 | 139 | if (!window) 140 | { 141 | return false; 142 | } 143 | 144 | return OnCreate(); 145 | } 146 | 147 | // static 148 | LRESULT CALLBACK Win32Window::WndProc(HWND const window, 149 | UINT const message, 150 | WPARAM const wparam, 151 | LPARAM const lparam) noexcept 152 | { 153 | if (message == WM_NCCREATE) 154 | { 155 | auto window_struct = reinterpret_cast(lparam); 156 | SetWindowLongPtr(window, GWLP_USERDATA, 157 | reinterpret_cast(window_struct->lpCreateParams)); 158 | 159 | auto that = static_cast(window_struct->lpCreateParams); 160 | EnableFullDpiSupportIfAvailable(window); 161 | that->window_handle_ = window; 162 | } 163 | else if (Win32Window *that = GetThisFromHandle(window)) 164 | { 165 | return that->MessageHandler(window, message, wparam, lparam); 166 | } 167 | 168 | return DefWindowProc(window, message, wparam, lparam); 169 | } 170 | 171 | LRESULT 172 | Win32Window::MessageHandler(HWND hwnd, 173 | UINT const message, 174 | WPARAM const wparam, 175 | LPARAM const lparam) noexcept 176 | { 177 | switch (message) 178 | { 179 | case WM_DESTROY: 180 | window_handle_ = nullptr; 181 | Destroy(); 182 | if (quit_on_close_) 183 | { 184 | PostQuitMessage(0); 185 | } 186 | return 0; 187 | 188 | case WM_DPICHANGED: 189 | { 190 | auto newRectSize = reinterpret_cast(lparam); 191 | LONG newWidth = newRectSize->right - newRectSize->left; 192 | LONG newHeight = newRectSize->bottom - newRectSize->top; 193 | 194 | SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, 195 | newHeight, SWP_NOZORDER | SWP_NOACTIVATE); 196 | 197 | return 0; 198 | } 199 | case WM_SIZE: 200 | RECT rect = GetClientArea(); 201 | if (child_content_ != nullptr) 202 | { 203 | // Size and position the child window. 204 | MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, 205 | rect.bottom - rect.top, TRUE); 206 | } 207 | return 0; 208 | 209 | case WM_ACTIVATE: 210 | if (child_content_ != nullptr) 211 | { 212 | SetFocus(child_content_); 213 | } 214 | return 0; 215 | } 216 | 217 | return DefWindowProc(window_handle_, message, wparam, lparam); 218 | } 219 | 220 | void Win32Window::Destroy() 221 | { 222 | OnDestroy(); 223 | 224 | if (window_handle_) 225 | { 226 | DestroyWindow(window_handle_); 227 | window_handle_ = nullptr; 228 | } 229 | if (g_active_window_count == 0) 230 | { 231 | WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); 232 | } 233 | } 234 | 235 | Win32Window *Win32Window::GetThisFromHandle(HWND const window) noexcept 236 | { 237 | return reinterpret_cast( 238 | GetWindowLongPtr(window, GWLP_USERDATA)); 239 | } 240 | 241 | void Win32Window::SetChildContent(HWND content) 242 | { 243 | child_content_ = content; 244 | SetParent(content, window_handle_); 245 | RECT frame = GetClientArea(); 246 | 247 | MoveWindow(content, frame.left, frame.top, frame.right - frame.left, 248 | frame.bottom - frame.top, true); 249 | 250 | SetFocus(child_content_); 251 | } 252 | 253 | RECT Win32Window::GetClientArea() 254 | { 255 | RECT frame; 256 | GetClientRect(window_handle_, &frame); 257 | return frame; 258 | } 259 | 260 | HWND Win32Window::GetHandle() 261 | { 262 | return window_handle_; 263 | } 264 | 265 | void Win32Window::SetQuitOnClose(bool quit_on_close) 266 | { 267 | quit_on_close_ = quit_on_close; 268 | } 269 | 270 | bool Win32Window::OnCreate() 271 | { 272 | // No-op; provided for subclasses. 273 | return true; 274 | } 275 | 276 | void Win32Window::OnDestroy() 277 | { 278 | // No-op; provided for subclasses. 279 | } 280 | -------------------------------------------------------------------------------- /lib/screens/playlist_page.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 | import 'package:line_icons/line_icons.dart'; 4 | import 'package:tsacdop_desktop/models/episodebrief.dart'; 5 | import 'package:tsacdop_desktop/providers/audio_state.dart'; 6 | 7 | import '../utils/extension_helper.dart'; 8 | import '../storage/sqflite_db.dart'; 9 | 10 | class PlaylistPage extends ConsumerWidget { 11 | const PlaylistPage({Key? key}) : super(key: key); 12 | 13 | @override 14 | Widget build(BuildContext context, WidgetRef ref) { 15 | final s = context.s!; 16 | 17 | return Column( 18 | children: [ 19 | Container( 20 | height: 100, 21 | width: double.infinity, 22 | padding: EdgeInsets.symmetric(horizontal: 40), 23 | alignment: Alignment.centerLeft, 24 | child: Consumer( 25 | builder: (context, watch, child) { 26 | final audio = ref.watch(audioState); 27 | return Row( 28 | mainAxisAlignment: MainAxisAlignment.start, 29 | children: [ 30 | child!, 31 | SizedBox(width: 20), 32 | audio.playerRunning 33 | ? IconButton( 34 | splashRadius: 20, 35 | icon: Icon(LineIcons.stepForward), 36 | onPressed: audio.playNext, 37 | ) 38 | : IconButton( 39 | splashRadius: 20, 40 | onPressed: ref.read(audioState).loadPlaylist, 41 | icon: Icon(Icons.play_arrow), 42 | ) 43 | ], 44 | ); 45 | }, 46 | child: Text(s.homeMenuPlaylist, style: context.textTheme.headline5), 47 | ), 48 | ), 49 | Padding( 50 | padding: const EdgeInsets.symmetric(horizontal: 20), 51 | child: Divider(height: 1), 52 | ), 53 | Expanded( 54 | child: Consumer( 55 | builder: (context, watch, child) { 56 | final audio = ref.watch(audioState); 57 | if (audio.queue!.isNotEmpty) 58 | return FutureBuilder>( 59 | future: _getEpisode(audio.queue!), 60 | initialData: [], 61 | builder: (context, snapshot) { 62 | var episodes = snapshot.data; 63 | return !snapshot.hasData 64 | ? Center() 65 | : ListView.builder( 66 | shrinkWrap: true, 67 | itemCount: snapshot.data!.length, 68 | padding: EdgeInsets.symmetric( 69 | vertical: 20, horizontal: 30), 70 | itemBuilder: (context, index) { 71 | final episode = episodes![index]; 72 | return ListTile( 73 | leading: CircleAvatar( 74 | backgroundColor: episodes[index] 75 | .backgroudColor(context) 76 | .withOpacity(0.5), 77 | backgroundImage: 78 | episodes[index].avatarImage), 79 | title: Text( 80 | episodes[index].title!, 81 | style: context.textTheme.bodyText1! 82 | .copyWith(fontWeight: FontWeight.bold), 83 | ), 84 | subtitle: Container( 85 | padding: EdgeInsets.symmetric(horizontal: 5), 86 | height: 50, 87 | child: Row( 88 | children: [ 89 | if (episode.explicit == 1) 90 | Container( 91 | decoration: BoxDecoration( 92 | color: Colors.red[800], 93 | borderRadius: 94 | BorderRadius.circular(4)), 95 | height: 23.0, 96 | width: 23.0, 97 | margin: 98 | EdgeInsets.only(right: 10.0), 99 | alignment: Alignment.center, 100 | child: Text('E', 101 | style: TextStyle( 102 | color: Colors.white))), 103 | if (episode.duration != 0) 104 | _episodeTag( 105 | episode.duration == 0 106 | ? '' 107 | : s.minsCount( 108 | episode.duration! ~/ 60), 109 | Colors.cyan[300]), 110 | if (episode.enclosureLength != null) 111 | _episodeTag( 112 | episode.enclosureLength == 0 113 | ? '' 114 | : '${episode.enclosureLength! ~/ 1000000}MB', 115 | Colors.lightBlue[300]), 116 | ], 117 | ), 118 | ), 119 | // trailing: ElevatedButton( 120 | // child: Text(s.play), 121 | // style: ElevatedButton.styleFrom( 122 | // elevation: 0, 123 | // splashFactory: NoSplash.splashFactory, 124 | // shadowColor: Colors.transparent, 125 | // onSurface: Colors.transparent, 126 | // primary: context.accentColor, 127 | // shape: RoundedRectangleBorder( 128 | // borderRadius: BorderRadius.circular(4)), 129 | // padding: EdgeInsets.zero, 130 | // minimumSize: Size(100, 40), 131 | // ), 132 | // onPressed: () { 133 | // context 134 | // .read(audioState) 135 | // .loadEpisode(episode.enclosureUrl); 136 | // }, 137 | // ), 138 | ); 139 | }, 140 | ); 141 | }, 142 | ); 143 | return Center(); 144 | }, 145 | ), 146 | ) 147 | ], 148 | ); 149 | } 150 | 151 | Future> _getEpisode(List urls) async { 152 | final dbHelper = DBHelper(); 153 | List episodes = []; 154 | for (var url in urls) { 155 | final episode = await dbHelper.getRssItemWithUrl(url); 156 | if (episode != null) episodes.add(episode); 157 | } 158 | return episodes; 159 | } 160 | 161 | Widget _episodeTag(String text, Color? color) { 162 | if (text == '') { 163 | return Center(); 164 | } 165 | return Container( 166 | decoration: 167 | BoxDecoration(color: color, borderRadius: BorderRadius.circular(20)), 168 | height: 25.0, 169 | margin: EdgeInsets.only(right: 10.0), 170 | padding: EdgeInsets.symmetric(horizontal: 10.0), 171 | alignment: Alignment.center, 172 | child: Text(text, style: TextStyle(fontSize: 14.0, color: Colors.black)), 173 | ); 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /lib/widgets/color_picker.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | import '../utils/extension_helper.dart'; 4 | 5 | class ColorPicker extends StatefulWidget { 6 | final ValueChanged? onColorChanged; 7 | ColorPicker({Key? key, this.onColorChanged}) : super(key: key); 8 | @override 9 | _ColorPickerState createState() => _ColorPickerState(); 10 | } 11 | 12 | class _ColorPickerState extends State 13 | with SingleTickerProviderStateMixin { 14 | TabController? _controller; 15 | int? _index; 16 | @override 17 | void initState() { 18 | super.initState(); 19 | _index = 0; 20 | _controller = TabController(length: Colors.primaries.length, vsync: this) 21 | ..addListener(() { 22 | setState(() => _index = _controller!.index); 23 | }); 24 | } 25 | 26 | Widget _colorCircle(Color color) => Material( 27 | color: Colors.transparent, 28 | child: InkWell( 29 | onTap: () => widget.onColorChanged!(color), 30 | child: Container( 31 | decoration: BoxDecoration( 32 | border: color == context.accentColor 33 | ? Border.all(color: Colors.grey[400]!, width: 4) 34 | : null, 35 | color: color), 36 | ), 37 | ), 38 | ); 39 | 40 | List _accentList(MaterialAccentColor color) => [ 41 | _colorCircle(color.shade100), 42 | _colorCircle(color.shade200), 43 | _colorCircle(color.shade400), 44 | _colorCircle(color.shade700) 45 | ]; 46 | 47 | @override 48 | Widget build(BuildContext context) { 49 | return Container( 50 | width: 300, 51 | height: 240, 52 | child: Column( 53 | crossAxisAlignment: CrossAxisAlignment.start, 54 | mainAxisAlignment: MainAxisAlignment.start, 55 | mainAxisSize: MainAxisSize.min, 56 | children: [ 57 | Container( 58 | height: 40, 59 | color: context.primaryColorDark, 60 | child: TabBar( 61 | labelPadding: EdgeInsets.symmetric(horizontal: 10), 62 | controller: _controller, 63 | indicatorColor: Colors.transparent, 64 | indicatorSize: TabBarIndicatorSize.tab, 65 | isScrollable: true, 66 | tabs: Colors.primaries 67 | .map((color) => Tab( 68 | child: Container( 69 | height: 20, 70 | width: 40, 71 | decoration: BoxDecoration( 72 | border: Colors.primaries.indexOf(color) == _index 73 | ? Border.all( 74 | color: Colors.grey[400]!, width: 2) 75 | : null, 76 | borderRadius: 77 | BorderRadius.all(Radius.circular(5)), 78 | color: color), 79 | ), 80 | )) 81 | .toList(), 82 | ), 83 | ), 84 | Expanded( 85 | child: TabBarView( 86 | physics: const ClampingScrollPhysics(), 87 | key: UniqueKey(), 88 | controller: _controller, 89 | children: Colors.primaries 90 | .map((color) => GridView.count( 91 | primary: false, 92 | padding: const EdgeInsets.fromLTRB(2, 10, 2, 10), 93 | crossAxisSpacing: 4, 94 | mainAxisSpacing: 4, 95 | crossAxisCount: 5, 96 | children: [ 97 | _colorCircle(color.shade100), 98 | _colorCircle(color.shade200), 99 | _colorCircle(color.shade300), 100 | _colorCircle(color.shade400), 101 | _colorCircle(color.shade500), 102 | _colorCircle(color.shade600), 103 | _colorCircle(color.shade700), 104 | _colorCircle(color.shade800), 105 | _colorCircle(color.shade900), 106 | ...color == Colors.red 107 | ? _accentList(Colors.redAccent) 108 | : color == Colors.pink 109 | ? _accentList(Colors.pinkAccent) 110 | : color == Colors.deepOrange 111 | ? _accentList(Colors.deepOrangeAccent) 112 | : color == Colors.orange 113 | ? _accentList(Colors.orangeAccent) 114 | : color == Colors.amber 115 | ? _accentList(Colors.amberAccent) 116 | : color == Colors.yellow 117 | ? _accentList( 118 | Colors.yellowAccent) 119 | : color == Colors.lime 120 | ? _accentList( 121 | Colors.limeAccent) 122 | : color == 123 | Colors.lightGreen 124 | ? _accentList(Colors 125 | .lightGreenAccent) 126 | : color == 127 | Colors.green 128 | ? _accentList(Colors 129 | .greenAccent) 130 | : color == 131 | Colors 132 | .teal 133 | ? _accentList( 134 | Colors 135 | .tealAccent) 136 | : color == 137 | Colors 138 | .cyan 139 | ? _accentList( 140 | Colors 141 | .cyanAccent) 142 | : color == 143 | Colors.lightBlue 144 | ? _accentList(Colors.lightBlueAccent) 145 | : color == Colors.blue 146 | ? _accentList(Colors.blueAccent) 147 | : color == Colors.indigo 148 | ? _accentList(Colors.indigoAccent) 149 | : color == Colors.purple 150 | ? _accentList(Colors.purpleAccent) 151 | : color == Colors.deepPurple 152 | ? _accentList(Colors.deepPurpleAccent) 153 | : [] 154 | ], 155 | )) 156 | .toList(), 157 | ), 158 | ), 159 | ], 160 | ), 161 | ); 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /lib/widgets/episode_menu.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 | import 'package:tsacdop_desktop/providers/audio_state.dart'; 4 | import 'package:tsacdop_desktop/providers/downloader.dart'; 5 | import 'package:tsacdop_desktop/widgets/custom_paint.dart'; 6 | 7 | import '../storage/sqflite_db.dart'; 8 | import '../utils/extension_helper.dart'; 9 | import '../models/episodebrief.dart'; 10 | 11 | class MenuButton extends StatelessWidget { 12 | final Function? onTap; 13 | final Widget? child; 14 | const MenuButton({this.child, this.onTap, Key? key}) : super(key: key); 15 | 16 | @override 17 | Widget build(BuildContext context) { 18 | return Material( 19 | color: Colors.transparent, 20 | child: InkWell( 21 | onTap: onTap as void Function()?, 22 | child: Container( 23 | height: 40.0, 24 | alignment: Alignment.center, 25 | padding: EdgeInsets.symmetric(horizontal: 15.0), 26 | child: child), 27 | ), 28 | ); 29 | } 30 | } 31 | 32 | class FavIcon extends StatefulWidget { 33 | final EpisodeBrief episode; 34 | FavIcon(this.episode, {Key? key}) : super(key: key); 35 | 36 | @override 37 | FavIconState createState() => FavIconState(); 38 | } 39 | 40 | class FavIconState extends State { 41 | var _dbHelper = DBHelper(); 42 | Future _isLiked(EpisodeBrief episode) async { 43 | return await _dbHelper.isLiked(episode.enclosureUrl); 44 | } 45 | 46 | Future _saveLiked(EpisodeBrief episode) async { 47 | await _dbHelper.setLiked(episode.enclosureUrl); 48 | if (mounted) setState(() {}); 49 | } 50 | 51 | Future _setUnliked(EpisodeBrief episode) async { 52 | await _dbHelper.setUniked(episode.enclosureUrl); 53 | if (mounted) setState(() {}); 54 | } 55 | 56 | @override 57 | Widget build(BuildContext context) { 58 | return FutureBuilder( 59 | future: _isLiked(widget.episode), 60 | initialData: false, 61 | builder: (context, snapshot) { 62 | return snapshot.data ?? false 63 | ? MenuButton( 64 | onTap: () => _setUnliked(widget.episode), 65 | child: Icon(Icons.favorite, color: Colors.red, size: 20), 66 | ) 67 | : MenuButton( 68 | onTap: () => _saveLiked(widget.episode), 69 | child: Icon(Icons.favorite_border, size: 20)); 70 | }, 71 | ); 72 | } 73 | } 74 | 75 | class DownloadIcon extends ConsumerStatefulWidget { 76 | final EpisodeBrief episode; 77 | DownloadIcon(this.episode, {Key? key}) : super(key: key); 78 | 79 | @override 80 | _DownloadIconState createState() => _DownloadIconState(); 81 | } 82 | 83 | class _DownloadIconState extends ConsumerState { 84 | Future _isDownloaded() async { 85 | var dbHelper = DBHelper(); 86 | return await dbHelper.isDownloaded(widget.episode.enclosureUrl); 87 | } 88 | 89 | Future _deleleDonwload() async { 90 | await ref.read(downloadProvider.notifier).deleteDownload(widget.episode); 91 | if (mounted) setState(() {}); 92 | } 93 | 94 | void _download() { 95 | ref.read(downloadProvider.notifier).download(widget.episode); 96 | setState(() {}); 97 | } 98 | 99 | @override 100 | Widget build(BuildContext context) { 101 | return FutureBuilder( 102 | future: _isDownloaded(), 103 | initialData: false, 104 | builder: (context, snapshot) { 105 | if (snapshot.data ?? false) 106 | return MenuButton( 107 | onTap: _deleleDonwload, 108 | child: SizedBox( 109 | height: 20, 110 | width: 20, 111 | child: CustomPaint( 112 | painter: DownloadPainter( 113 | color: context.accentColor, 114 | fraction: 1, 115 | progressColor: context.accentColor, 116 | progress: 1, 117 | ), 118 | ), 119 | ), 120 | ); 121 | return Consumer( 122 | builder: (context, watch, child) { 123 | final tasks = ref.watch(downloadProvider); 124 | final index = 125 | ref.read(downloadProvider.notifier).indexOf(widget.episode); 126 | if (index == -1) 127 | return Material( 128 | color: Colors.transparent, 129 | child: InkWell( 130 | onTap: _download, 131 | child: Container( 132 | height: 50.0, 133 | alignment: Alignment.center, 134 | padding: EdgeInsets.symmetric(horizontal: 15.0), 135 | child: SizedBox( 136 | height: 20, 137 | width: 20, 138 | child: CustomPaint( 139 | painter: DownloadPainter( 140 | color: Colors.grey[700], 141 | fraction: 0, 142 | progressColor: context.accentColor, 143 | ), 144 | ), 145 | ), 146 | ), 147 | ), 148 | ); 149 | return tasks[index].status != DownloadTaskStatus.complete 150 | ? MenuButton( 151 | onTap: () => ref 152 | .read(downloadProvider.notifier) 153 | .cancelDownload(tasks[index]), 154 | child: TweenAnimationBuilder( 155 | duration: Duration(milliseconds: 1000), 156 | tween: Tween(begin: 0.0, end: 1.0), 157 | builder: (context, dynamic fraction, child) => SizedBox( 158 | height: 20, 159 | width: 20, 160 | child: CustomPaint( 161 | painter: DownloadPainter( 162 | color: context.accentColor, 163 | fraction: fraction, 164 | progressColor: context.accentColor, 165 | progress: (tasks[index].progress ?? 0) / 100), 166 | )))) 167 | : MenuButton( 168 | onTap: _deleleDonwload, 169 | child: SizedBox( 170 | height: 20, 171 | width: 20, 172 | child: CustomPaint( 173 | painter: DownloadPainter( 174 | color: context.accentColor, 175 | fraction: 1, 176 | progressColor: context.accentColor, 177 | progress: 1, 178 | ), 179 | ), 180 | ), 181 | ); 182 | }, 183 | ); 184 | }, 185 | ); 186 | } 187 | } 188 | 189 | class PlaylistButton extends ConsumerWidget { 190 | final EpisodeBrief episode; 191 | PlaylistButton(this.episode, {Key? key}) : super(key: key); 192 | 193 | @override 194 | Widget build(BuildContext context, WidgetRef ref) { 195 | final queue = ref.watch(audioState).queue!; 196 | final url = episode.enclosureUrl; 197 | if (queue.contains(url)) 198 | return MenuButton( 199 | onTap: () => ref.read(audioState).removeFromPlaylist(url), 200 | child: Icon(Icons.playlist_add_check, 201 | color: context.accentColor, size: 20), 202 | ); 203 | return MenuButton( 204 | onTap: () => ref.read(audioState).addToPlaylist(url), 205 | child: Icon(Icons.playlist_add, size: 20), 206 | ); 207 | } 208 | } 209 | 210 | class PlayButton extends ConsumerStatefulWidget { 211 | final EpisodeBrief episode; 212 | PlayButton(this.episode, {Key? key}) : super(key: key); 213 | 214 | @override 215 | _PlayButtonState createState() => _PlayButtonState(); 216 | } 217 | 218 | class _PlayButtonState extends ConsumerState { 219 | Future _isDownloaded() async { 220 | var dbHelper = DBHelper(); 221 | return await dbHelper.isDownloaded(widget.episode.enclosureUrl); 222 | } 223 | 224 | @override 225 | Widget build(BuildContext context) { 226 | return Material( 227 | color: Colors.transparent, 228 | child: InkWell( 229 | onTap: () => 230 | ref.watch(audioState).loadEpisode(widget.episode.enclosureUrl), 231 | borderRadius: BorderRadius.only(bottomRight: Radius.circular(6)), 232 | highlightColor: context.accentColor.withAlpha(70), 233 | child: Stack( 234 | alignment: Alignment.center, 235 | children: [ 236 | Container( 237 | height: 40, 238 | width: 120, 239 | decoration: BoxDecoration( 240 | color: context.accentColor, 241 | borderRadius: 242 | BorderRadius.only(bottomRight: Radius.circular(6)), 243 | ), 244 | ), 245 | Consumer(builder: (context, watch, child) { 246 | final tasks = ref.watch(downloadProvider); 247 | final index = 248 | ref.watch(downloadProvider.notifier).indexOf(widget.episode); 249 | if (index == -1) return Center(); 250 | return tasks[index].status == DownloadTaskStatus.running 251 | ? Positioned( 252 | left: 0, 253 | child: Container( 254 | height: 40, 255 | width: (tasks[index].progress ?? 0) * 1.2, 256 | color: context.accentColor), 257 | ) 258 | : Center(); 259 | }), 260 | Container( 261 | height: 40, 262 | child: Padding( 263 | padding: const EdgeInsets.symmetric(horizontal: 20.0), 264 | child: Row( 265 | children: [ 266 | Text(context.s!.play, 267 | style: TextStyle( 268 | color: Colors.white, fontWeight: FontWeight.bold)), 269 | SizedBox(width: 10), 270 | Icon(Icons.cloud_download, color: Colors.white) 271 | ], 272 | ), 273 | ), 274 | ), 275 | ], 276 | ), 277 | ), 278 | ); 279 | } 280 | } 281 | -------------------------------------------------------------------------------- /lib/screens/podcasts_page.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 | import 'package:resizable_widget/resizable_widget.dart'; 4 | 5 | import '../models/episodebrief.dart'; 6 | import '../models/podcastlocal.dart'; 7 | import '../providers/group_state.dart'; 8 | import '../utils/extension_helper.dart'; 9 | import '../widgets/custom_dropdown.dart'; 10 | import '../widgets/custom_list_tile.dart'; 11 | import 'episode_detail.dart'; 12 | import 'home_tabs.dart'; 13 | import 'podcast_detail.dart'; 14 | 15 | final openPodcast = StateProvider((ref) => null); 16 | final openEpisode = StateProvider((ref) => null); 17 | 18 | class PodcastsPage extends ConsumerWidget { 19 | PodcastsPage({Key? key}) : super(key: key); 20 | 21 | @override 22 | Widget build(BuildContext context, WidgetRef ref) { 23 | final podcast = ref.watch(openPodcast); 24 | final episode = ref.watch(openEpisode); 25 | return SizedBox( 26 | width: double.infinity, 27 | height: double.infinity, 28 | child: ResizableWidget( 29 | isHorizontalSeparator: false, // optional 30 | isDisabledSmartHide: false, // optional 31 | separatorColor: context.primaryColorDark, // optional 32 | separatorSize: 1, 33 | percentages: [0.3, 0.7], 34 | children: [ 35 | _PodcastGroup(), 36 | if (episode != null) 37 | EpisodeDetail(episode) 38 | else if (podcast != null) 39 | PodcastDetail(podcast) 40 | else 41 | HomeTabs() 42 | ], 43 | ), 44 | ); 45 | } 46 | } 47 | 48 | class _PodcastGroup extends ConsumerStatefulWidget { 49 | const _PodcastGroup({Key? key}) : super(key: key); 50 | 51 | @override 52 | __PodcastGroupState createState() => __PodcastGroupState(); 53 | } 54 | 55 | class __PodcastGroupState extends ConsumerState<_PodcastGroup> { 56 | int? _groupIndex; 57 | @override 58 | void initState() { 59 | super.initState(); 60 | _groupIndex = 0; 61 | } 62 | 63 | @override 64 | Widget build(BuildContext context) { 65 | return Consumer(builder: (context, watch, child) { 66 | final groupList = ref.watch(groupState); 67 | if (groupList.isEmpty) { 68 | return Center(); 69 | } 70 | return Column( 71 | mainAxisAlignment: MainAxisAlignment.start, 72 | children: [ 73 | Padding( 74 | padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8.0), 75 | child: ListTile( 76 | leading: MyDropdownButton( 77 | hint: Center(), 78 | underline: Center(), 79 | elevation: 0, 80 | displayItemCount: 5, 81 | value: _groupIndex, 82 | dropdownColor: context.primaryColorDark, 83 | onChanged: (value) { 84 | setState(() => _groupIndex = value); 85 | }, 86 | items: [ 87 | for (var group in groupList) 88 | DropdownMenuItem( 89 | value: groupList.indexOf(group), 90 | child: Text(group.name ?? '')) 91 | ], 92 | ), 93 | trailing: IconButton( 94 | splashRadius: 20, 95 | icon: Icon(Icons.add), 96 | onPressed: () { 97 | showGeneralDialog( 98 | context: context, 99 | barrierDismissible: true, 100 | barrierLabel: MaterialLocalizations.of(context) 101 | .modalBarrierDismissLabel, 102 | barrierColor: Colors.transparent, 103 | transitionDuration: const Duration(milliseconds: 200), 104 | pageBuilder: (context, animaiton, secondaryAnimation) => 105 | AddGroup()); 106 | }, 107 | ), 108 | ), 109 | ), 110 | FutureBuilder>( 111 | future: groupList[_groupIndex!].getPodcasts(), 112 | initialData: [], 113 | builder: (context, snapshot) { 114 | if (snapshot.data!.isEmpty) return Center(); 115 | return Expanded( 116 | child: ListView( 117 | shrinkWrap: true, 118 | children: [ 119 | for (var podcast in snapshot.data!) 120 | _podcastListTile(context, podcast) 121 | ], 122 | ), 123 | ); 124 | }, 125 | ), 126 | ], 127 | ); 128 | }); 129 | } 130 | 131 | Widget _podcastListTile(BuildContext context, PodcastLocal podcast) => 132 | Consumer( 133 | builder: (context, ref, _) { 134 | final selected = ref.watch(openPodcast) == podcast; 135 | return CustomListTile( 136 | selected: selected, 137 | onTap: () { 138 | ref.watch(openEpisode.notifier).state = null; 139 | ref.watch(openPodcast.notifier).state = podcast; 140 | }, 141 | child: Row( 142 | children: [ 143 | Padding( 144 | padding: 145 | const EdgeInsets.symmetric(vertical: 20, horizontal: 10), 146 | child: CircleAvatar( 147 | backgroundColor: 148 | podcast.backgroudColor(context).withOpacity(0.5), 149 | backgroundImage: podcast.avatarImage), 150 | ), 151 | Expanded( 152 | child: Column( 153 | crossAxisAlignment: CrossAxisAlignment.start, 154 | children: [ 155 | Text(podcast.title!, 156 | maxLines: 1, 157 | style: context.textTheme.bodyText1! 158 | .copyWith(fontWeight: FontWeight.bold)), 159 | Text( 160 | podcast.author!, 161 | maxLines: 1, 162 | ), 163 | ], 164 | ), 165 | ), 166 | ], 167 | ), 168 | ); 169 | }, 170 | ); 171 | } 172 | 173 | class AddGroup extends ConsumerStatefulWidget { 174 | @override 175 | _AddGroupState createState() => _AddGroupState(); 176 | } 177 | 178 | class _AddGroupState extends ConsumerState { 179 | TextEditingController? _controller; 180 | String? _newGroup; 181 | int? _error; 182 | 183 | @override 184 | void initState() { 185 | super.initState(); 186 | _error = 0; 187 | _controller = TextEditingController(); 188 | } 189 | 190 | @override 191 | void dispose() { 192 | _controller!.dispose(); 193 | super.dispose(); 194 | } 195 | 196 | @override 197 | Widget build(BuildContext context) { 198 | final s = context.s!; 199 | return Stack( 200 | children: [ 201 | Positioned( 202 | top: 20, 203 | left: 40, 204 | child: AlertDialog( 205 | shape: 206 | RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), 207 | elevation: 4, 208 | contentPadding: const EdgeInsets.symmetric(horizontal: 20), 209 | titlePadding: EdgeInsets.all(20), 210 | actionsPadding: EdgeInsets.all(4), 211 | actions: [ 212 | TextButton( 213 | style: OutlinedButton.styleFrom( 214 | primary: context.textColor, 215 | splashFactory: NoSplash.splashFactory, 216 | padding: EdgeInsets.symmetric(horizontal: 20, vertical: 15), 217 | shape: 218 | RoundedRectangleBorder(borderRadius: BorderRadius.zero), 219 | ), 220 | onPressed: () => Navigator.of(context).pop(), 221 | child: Text( 222 | s.cancel, 223 | style: TextStyle(color: Colors.grey[600]), 224 | ), 225 | ), 226 | TextButton( 227 | style: OutlinedButton.styleFrom( 228 | primary: context.textColor, 229 | splashFactory: NoSplash.splashFactory, 230 | padding: EdgeInsets.symmetric(horizontal: 20, vertical: 15), 231 | shape: 232 | RoundedRectangleBorder(borderRadius: BorderRadius.zero), 233 | ), 234 | onPressed: () async { 235 | if (ref.watch(groupState.notifier).isExisted(_newGroup)) { 236 | setState(() => _error = 1); 237 | } else { 238 | ref 239 | .watch(groupState.notifier) 240 | .addGroup(PodcastGroup(_newGroup)); 241 | Navigator.of(context).pop(); 242 | } 243 | }, 244 | child: Text(s.confirm, 245 | style: TextStyle(color: context.accentColor)), 246 | ) 247 | ], 248 | title: SizedBox(child: Text(s.newGroup)), 249 | content: Column( 250 | mainAxisSize: MainAxisSize.min, 251 | children: [ 252 | TextField( 253 | decoration: InputDecoration( 254 | contentPadding: EdgeInsets.symmetric(horizontal: 10), 255 | hintText: s.newGroup, 256 | hintStyle: TextStyle(fontSize: 18), 257 | filled: true, 258 | focusedBorder: UnderlineInputBorder( 259 | borderSide: 260 | BorderSide(color: context.accentColor, width: 2.0), 261 | ), 262 | enabledBorder: UnderlineInputBorder( 263 | borderSide: 264 | BorderSide(color: context.accentColor, width: 2.0), 265 | ), 266 | ), 267 | cursorRadius: Radius.circular(2), 268 | autofocus: true, 269 | maxLines: 1, 270 | controller: _controller, 271 | onChanged: (value) { 272 | _newGroup = value; 273 | }, 274 | ), 275 | Container( 276 | alignment: Alignment.centerLeft, 277 | child: (_error == 1) 278 | ? Text( 279 | s.groupExisted, 280 | style: TextStyle(color: Colors.red[400]), 281 | ) 282 | : Center(), 283 | ), 284 | ], 285 | ), 286 | ), 287 | ), 288 | ], 289 | ); 290 | } 291 | } 292 | -------------------------------------------------------------------------------- /lib/screens/home.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 | import 'package:line_icons/line_icons.dart'; 4 | import 'package:tsacdop_desktop/screens/player.dart'; 5 | 6 | import 'about.dart'; 7 | import 'home_tabs.dart'; 8 | import 'podcasts_page.dart'; 9 | import 'search.dart'; 10 | import 'playlist_page.dart'; 11 | import 'settings.dart'; 12 | import '../providers/downloader.dart'; 13 | import '../providers/group_state.dart'; 14 | import '../widgets/custom_button.dart'; 15 | import '../utils/extension_helper.dart'; 16 | import '../providers/settings_state.dart'; 17 | 18 | class Home extends ConsumerStatefulWidget { 19 | const Home({Key? key}) : super(key: key); 20 | 21 | @override 22 | _HomeState createState() => _HomeState(); 23 | } 24 | 25 | class _HomeState extends ConsumerState { 26 | Widget? _body; 27 | String? _selectMenu; 28 | OverlayEntry? _overlayEntry; 29 | @override 30 | void initState() { 31 | _body = PodcastsPage(); 32 | _selectMenu = 'home'; 33 | super.initState(); 34 | } 35 | 36 | OverlayEntry _createOverlayEntry() { 37 | RenderBox renderBox = context.findRenderObject() as RenderBox; 38 | var offset = renderBox.localToGlobal(Offset.zero); 39 | return OverlayEntry( 40 | builder: (constext) => Positioned( 41 | bottom: offset.dx + 50, 42 | left: offset.dy + 60, 43 | child: LimitedBox( 44 | maxHeight: 300, 45 | child: FittedBox( 46 | alignment: Alignment.bottomCenter, 47 | child: Material( 48 | child: Container( 49 | width: 300, 50 | decoration: BoxDecoration( 51 | color: Colors.grey[200], 52 | borderRadius: BorderRadius.circular(4.0), 53 | border: Border.all(color: context.primaryColorDark)), 54 | child: Consumer(builder: (context, watch, child) { 55 | var tasks = ref.watch(downloadProvider); 56 | if (tasks.isEmpty) 57 | return SizedBox( 58 | height: 10, 59 | ); 60 | return ListView.builder( 61 | shrinkWrap: true, 62 | itemCount: tasks.length, 63 | itemBuilder: (context, index) { 64 | final task = tasks[index]; 65 | return ListTile( 66 | leading: Text(tasks[index].progress?.toString() ?? ''), 67 | title: Text( 68 | task.episode.title ?? '', 69 | maxLines: 1, 70 | ), 71 | subtitle: SizedBox( 72 | height: 4, 73 | child: LinearProgressIndicator( 74 | value: (tasks[index].progress ?? 0) / 100, 75 | ), 76 | ), 77 | ); 78 | }, 79 | ); 80 | }), 81 | ), 82 | ), 83 | ), 84 | ), 85 | ), 86 | ); 87 | } 88 | 89 | @override 90 | Widget build(BuildContext context) { 91 | return Scaffold( 92 | body: Stack( 93 | children: [ 94 | Column( 95 | children: [ 96 | Expanded( 97 | child: Row(children: [ 98 | Container( 99 | width: 50, 100 | height: double.infinity, 101 | decoration: BoxDecoration( 102 | color: context.primaryColor, 103 | ), 104 | child: Material( 105 | color: Colors.transparent, 106 | child: Column( 107 | children: [ 108 | SizedBox(height: 20), 109 | CustomIconButton( 110 | pressed: _selectMenu == 'home', 111 | icon: Icon( 112 | LineIcons.home, 113 | color: context.textColor, 114 | ), 115 | onPressed: () { 116 | setState(() { 117 | _body = PodcastsPage(); 118 | _selectMenu = 'home'; 119 | }); 120 | }, 121 | ), 122 | CustomIconButton( 123 | pressed: _selectMenu == 'playlist', 124 | icon: Icon( 125 | Icons.playlist_play, 126 | color: context.textColor, 127 | ), 128 | onPressed: () { 129 | setState(() { 130 | _body = PlaylistPage(); 131 | _selectMenu = 'playlist'; 132 | }); 133 | }, 134 | ), 135 | CustomIconButton( 136 | pressed: _selectMenu == 'search', 137 | icon: Icon( 138 | LineIcons.search, 139 | color: context.textColor, 140 | ), 141 | onPressed: () { 142 | setState(() { 143 | _body = SearchPage(); 144 | _selectMenu = 'search'; 145 | }); 146 | }, 147 | ), 148 | Spacer(), 149 | Consumer(builder: (context, watch, child) { 150 | var tasks = ref.watch(downloadProvider); 151 | if (tasks.isNotEmpty) 152 | return IconButton( 153 | splashRadius: 20, 154 | color: _overlayEntry != null 155 | ? context.accentColor 156 | : null, 157 | icon: Icon(LineIcons.bell), 158 | onPressed: () { 159 | if (_overlayEntry == null) { 160 | _overlayEntry = _createOverlayEntry(); 161 | Overlay.of(context)!.insert(_overlayEntry!); 162 | } else { 163 | _overlayEntry!.remove(); 164 | _overlayEntry = null; 165 | } 166 | setState(() {}); 167 | }, 168 | ); 169 | return Center(); 170 | }), 171 | IconButton( 172 | splashRadius: 20, 173 | icon: Icon(LineIcons.lightbulb), 174 | onPressed: () { 175 | if (ref.read(settings).themeMode != 176 | ThemeMode.dark) 177 | ref.read(settings).setTheme = ThemeMode.dark; 178 | else { 179 | ref.read(settings).setTheme = ThemeMode.light; 180 | } 181 | }, 182 | ), 183 | CustomIconButton( 184 | pressed: _selectMenu == 'settings', 185 | icon: Icon(LineIcons.cog), 186 | onPressed: () { 187 | setState(() { 188 | _body = Settings(); 189 | _selectMenu = 'settings'; 190 | }); 191 | }, 192 | ), 193 | CustomIconButton( 194 | pressed: _selectMenu == 'about', 195 | icon: Icon(LineIcons.infoCircle), 196 | onPressed: () { 197 | setState(() { 198 | _body = About(); 199 | _selectMenu = 'about'; 200 | }); 201 | }, 202 | ), 203 | ], 204 | ), 205 | ), 206 | ), 207 | Expanded( 208 | child: AnimatedSwitcher( 209 | child: _body, 210 | duration: Duration(milliseconds: 300), 211 | ), 212 | ), 213 | ]), 214 | ), 215 | PlayerWidget() 216 | ], 217 | ), 218 | Positioned( 219 | right: 0, 220 | bottom: 0, 221 | child: IgnorePointer(child: _NotificationBar())), 222 | ], 223 | ), 224 | ); 225 | } 226 | } 227 | 228 | class _NotificationBar extends ConsumerWidget { 229 | const _NotificationBar({Key? key}) : super(key: key); 230 | Widget _notifierText(String text, BuildContext context) { 231 | return Container( 232 | height: 30, 233 | decoration: BoxDecoration( 234 | color: Colors.grey[600]!.withOpacity(0.6), 235 | borderRadius: BorderRadius.only(topLeft: Radius.circular(4.0))), 236 | padding: EdgeInsets.symmetric(horizontal: 10), 237 | alignment: Alignment.centerRight, 238 | child: Text(text, maxLines: 1, style: TextStyle(color: Colors.white)), 239 | ); 240 | } 241 | 242 | @override 243 | Widget build(BuildContext context, WidgetRef ref) { 244 | final s = context.s; 245 | var refreshNotifier = ref.watch(refreshNotification); 246 | var item = ref.watch(currentSubscribeItem); 247 | var downloadNotifier = ref.watch(downloadNotification); 248 | if (downloadNotifier != null) { 249 | return _notifierText(downloadNotifier, context); 250 | } 251 | if (refreshNotifier != null) { 252 | return _notifierText(refreshNotifier, context); 253 | } 254 | if (item != null) 255 | switch (item.subscribeState) { 256 | case SubscribeState.start: 257 | return _notifierText(s!.notificationSubscribe(item.title!), context); 258 | case SubscribeState.subscribe: 259 | return _notifierText(s!.notificaitonFatch(item.title!), context); 260 | case SubscribeState.fetch: 261 | return _notifierText(s!.notificationSuccess(item.title!), context); 262 | case SubscribeState.exist: 263 | return _notifierText( 264 | s!.notificationSubscribeExisted(item.title!), context); 265 | case SubscribeState.error: 266 | return _notifierText( 267 | s!.notificationNetworkError(item.title!), context); 268 | default: 269 | return Center(); 270 | } 271 | return Center(); 272 | } 273 | } 274 | -------------------------------------------------------------------------------- /lib/screens/player.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:flutter_riverpod/flutter_riverpod.dart'; 5 | import 'package:line_icons/line_icons.dart'; 6 | import 'package:tsacdop_desktop/providers/audio_state.dart'; 7 | import 'package:tsacdop_desktop/widgets/custom_slider.dart'; 8 | import '../utils/extension_helper.dart'; 9 | import 'podcasts_page.dart'; 10 | 11 | class PlayerWidget extends ConsumerWidget { 12 | @override 13 | Widget build(BuildContext context, WidgetRef ref) { 14 | final audio = ref.watch(audioState); 15 | if (audio.playerRunning) 16 | return Container( 17 | height: 120, 18 | width: double.infinity, 19 | color: context.primaryColor, 20 | child: Material( 21 | color: Colors.transparent, 22 | child: Row( 23 | children: [ 24 | SizedBox( 25 | height: 120, 26 | width: 120, 27 | child: audio.playingEpisode == null 28 | ? Center() 29 | : Image.file(File("${audio.playingEpisode!.imagePath}")), 30 | ), 31 | InkWell( 32 | onTap: () => 33 | ref.read(openEpisode.notifier).state = audio.playingEpisode, 34 | child: SizedBox( 35 | width: 200, 36 | height: 120, 37 | child: Center( 38 | child: Padding( 39 | padding: EdgeInsets.symmetric(horizontal: 20), 40 | child: Text(audio.playingEpisode?.title ?? '', 41 | maxLines: 2, 42 | style: TextStyle(fontWeight: FontWeight.bold)), 43 | ), 44 | ), 45 | ), 46 | ), 47 | Expanded( 48 | child: Column( 49 | mainAxisAlignment: MainAxisAlignment.spaceAround, 50 | children: [ 51 | Material( 52 | color: Colors.transparent, 53 | child: Row( 54 | mainAxisAlignment: MainAxisAlignment.start, 55 | crossAxisAlignment: CrossAxisAlignment.center, 56 | children: [ 57 | Expanded( 58 | flex: 3, 59 | child: Row( 60 | mainAxisAlignment: MainAxisAlignment.spaceEvenly, 61 | crossAxisAlignment: CrossAxisAlignment.center, 62 | children: [ 63 | Row( 64 | children: [ 65 | IconButton( 66 | padding: EdgeInsets.zero, 67 | splashRadius: 20, 68 | icon: Icon(LineIcons.fastBackward), 69 | onPressed: () async => 70 | await audio.rewind(Duration(seconds: 15)), 71 | ), 72 | Text('15s') 73 | ], 74 | ), 75 | audio.buffering 76 | ? CircularProgressIndicator( 77 | color: context.accentColor, 78 | ) 79 | : audio.playing 80 | ? IconButton( 81 | padding: EdgeInsets.zero, 82 | splashRadius: 25, 83 | icon: Icon(LineIcons.pauseCircle, 84 | color: context.accentColor, 85 | size: 30), 86 | onPressed: audio.pauseAduio, 87 | ) 88 | : IconButton( 89 | padding: EdgeInsets.zero, 90 | splashRadius: 20, 91 | icon: Icon(LineIcons.playCircle, 92 | size: 30), 93 | onPressed: audio.play), 94 | Row( 95 | children: [ 96 | Text('15s'), 97 | IconButton( 98 | splashRadius: 20, 99 | icon: Icon(LineIcons.fastForward), 100 | onPressed: () async => await audio 101 | .fastForward(Duration(seconds: 15)), 102 | ), 103 | ], 104 | ), 105 | ], 106 | ), 107 | ), 108 | Expanded( 109 | flex: 1, 110 | child: Padding( 111 | padding: 112 | const EdgeInsets.symmetric(horizontal: 20.0), 113 | child: Row( 114 | mainAxisAlignment: MainAxisAlignment.start, 115 | children: [ 116 | IconButton( 117 | splashRadius: 20, 118 | onPressed: () => audio.setVolume(0), 119 | icon: Icon( 120 | LineIcons.volumeDown, 121 | ), 122 | ), 123 | Expanded( 124 | child: SliderTheme( 125 | data: SliderTheme.of(context).copyWith( 126 | activeTrackColor: 127 | context.primaryColorDark, 128 | inactiveTrackColor: 129 | context.primaryColorDark, 130 | activeTickMarkColor: 131 | context.primaryColorDark, 132 | trackHeight: 2.0, 133 | thumbColor: context.primaryColorDark, 134 | thumbShape: RoundSliderThumbShape( 135 | elevation: 0, 136 | enabledThumbRadius: 6.0, 137 | disabledThumbRadius: 6.0, 138 | ), 139 | overlayColor: context.primaryColorDark, 140 | overlayShape: RoundSliderOverlayShape( 141 | overlayRadius: 4.0), 142 | ), 143 | child: Slider( 144 | divisions: 10, 145 | label: 146 | audio.volume.toStringAsFixed(2), 147 | value: audio.volume, 148 | onChanged: (value) { 149 | audio.setVolume(value); 150 | }), 151 | ), 152 | ), 153 | ], 154 | ), 155 | ), 156 | ), 157 | // Expanded( 158 | // flex: 1, 159 | // child: Row( 160 | // mainAxisAlignment: MainAxisAlignment.center, 161 | // children: [ 162 | // IconButton( 163 | // splashRadius: 20, 164 | // icon: Icon(LineIcons.step_forward_solid), 165 | // onPressed: audio.playNext, 166 | // ), 167 | // IconButton( 168 | // splashRadius: 20, 169 | // icon: Icon(LineIcons.window_close_solid), 170 | // onPressed: audio.stop, 171 | // ), 172 | // ], 173 | // ), 174 | // ), 175 | ], 176 | ), 177 | ), 178 | Container( 179 | padding: EdgeInsets.only(top: 10, left: 30, right: 30), 180 | child: SliderTheme( 181 | data: SliderTheme.of(context).copyWith( 182 | activeTrackColor: context.accentColor.withAlpha(70), 183 | inactiveTrackColor: context.primaryColorDark, 184 | trackHeight: 4.0, 185 | trackShape: MyRectangularTrackShape(), 186 | thumbColor: context.accentColor, 187 | thumbShape: RoundSliderThumbShape( 188 | enabledThumbRadius: 6.0, 189 | disabledThumbRadius: 6.0, 190 | ), 191 | overlayColor: context.accentColor.withAlpha(32), 192 | overlayShape: 193 | RoundSliderOverlayShape(overlayRadius: 4.0), 194 | ), 195 | child: Slider( 196 | value: audio.duration == Duration.zero 197 | ? 0 198 | : audio.position!.inMilliseconds / 199 | audio.duration!.inMilliseconds, 200 | onChangeEnd: (value) { 201 | audio.slideSeek(value, end: true); 202 | }, 203 | onChanged: (value) => audio.slideSeek(value), 204 | ), 205 | ), 206 | ), 207 | Padding( 208 | padding: const EdgeInsets.symmetric(horizontal: 30.0), 209 | child: Row( 210 | mainAxisAlignment: MainAxisAlignment.spaceBetween, 211 | children: [ 212 | Text(audio.position!.inSeconds.toTime), 213 | Text(audio.duration!.inSeconds.toTime) 214 | ], 215 | ), 216 | ) 217 | ], 218 | ), 219 | ), 220 | ], 221 | ), 222 | ), 223 | ); 224 | return Center(); 225 | } 226 | } 227 | --------------------------------------------------------------------------------