diff --git a/.github/workflows/python-lint.yml b/.github/workflows/python-lint.yml index 9fe2faf..7abe24f 100644 --- a/.github/workflows/python-lint.yml +++ b/.github/workflows/python-lint.yml @@ -44,3 +44,7 @@ jobs: - name: Run ruff lint check run: ruff check main.py app --config .ruff.toml working-directory: . + + - name: Run ruff format check + run: ruff format --check --diff main.py app --config .ruff.toml + working-directory: . diff --git a/app/api/video_stream_service.py b/app/api/video_stream_service.py index 8d24f41..7d6aa36 100644 --- a/app/api/video_stream_service.py +++ b/app/api/video_stream_service.py @@ -30,7 +30,7 @@ os.makedirs(VIDEO_DIR, exist_ok=True) VIDEO_META_CACHE = TTLCache(maxsize=50, ttl=300) CHUNK_CACHE = TTLCache(maxsize=25, ttl=60) -if sys.platform == 'win32': +if sys.platform == "win32": asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) @@ -60,22 +60,18 @@ def validate_filename(filename: str): @app.get("/api/videos") -async def get_video( - request: Request, - filename: str = Query(...), - subfolder: str | None = None -): +async def get_video(request: Request, filename: str = Query(...), subfolder: str | None = None): cache_key = f"{filename}-{subfolder}" if meta := VIDEO_META_CACHE.get(cache_key): if_none_match = request.headers.get("If-None-Match") if_modified_since = request.headers.get("If-Modified-Since") - if if_none_match and if_none_match == meta['etag']: + if if_none_match and if_none_match == meta["etag"]: return Response(status_code=304) if if_modified_since: - last_modified = datetime.fromisoformat(meta['last_modified']) + last_modified = datetime.fromisoformat(meta["last_modified"]) if datetime.strptime(if_modified_since, "%a, %d %b %Y %H:%M:%S GMT") >= last_modified: return Response(status_code=304) @@ -106,11 +102,7 @@ async def get_video( last_modified = datetime.fromtimestamp(stat.st_mtime).isoformat() etag = hashlib.md5(f"{file_size}-{last_modified}".encode()).hexdigest() - VIDEO_META_CACHE[cache_key] = { - 'etag': etag, - 'last_modified': last_modified, - 'file_size': file_size - } + VIDEO_META_CACHE[cache_key] = {"etag": etag, "last_modified": last_modified, "file_size": file_size} # Parse Range header range_header = request.headers.get("Range") @@ -141,7 +133,7 @@ async def get_video( "Content-Type": "video/mp4", "Cache-Control": "public, max-age=300", "ETag": etag, - "Last-Modified": datetime.fromisoformat(last_modified).strftime("%a, %d %b %Y %H:%M:%S GMT") + "Last-Modified": datetime.fromisoformat(last_modified).strftime("%a, %d %b %Y %H:%M:%S GMT"), } try: return StreamingResponse(file_sender(video_path), headers=headers) diff --git a/app/app_manager.py b/app/app_manager.py index 99d59a7..ed0710a 100644 --- a/app/app_manager.py +++ b/app/app_manager.py @@ -61,7 +61,7 @@ class App: self.content_area, self.dialog_area, self.snack_bar_area, - ] + ], ) self.snack_bar = ShowSnackBar(self) self.subprocess_start_up_info = utils.get_startup_info() @@ -88,7 +88,6 @@ class App: async def switch_page(self, page_name): if self._loading_page: return - self._loading_page = True try: @@ -101,7 +100,7 @@ class App: self._loading_page = False async def clear_content_area(self): - self.content_area.clean() + self.content_area.controls.clear() self.content_area.update() async def cleanup(self): @@ -120,11 +119,11 @@ class App: try: if not self.update_checker.update_config["auto_check"]: return - + last_check_time = self.settings.user_config.get("last_update_check", 0) current_time = time.time() check_interval = self.update_checker.update_config["check_interval"] - + if current_time - last_check_time >= check_interval: update_info = await self.update_checker.check_for_updates() self.settings.user_config["last_update_check"] = current_time @@ -137,6 +136,4 @@ class App: async def start_periodic_tasks(self): """Start all periodic tasks""" - await self.record_manager.setup_periodic_live_check( - int(self.record_manager.loop_time_seconds or 180) - ) + await self.record_manager.setup_periodic_live_check(int(self.record_manager.loop_time_seconds or 180)) diff --git a/app/auth/auth_manager.py b/app/auth/auth_manager.py index d3f8c2e..cc3f3b1 100644 --- a/app/auth/auth_manager.py +++ b/app/auth/auth_manager.py @@ -6,88 +6,86 @@ from ..utils.logger import logger class AuthManager: - def __init__(self, app): self.app = app self.config_manager = app.config_manager self.is_authenticated = False self.session_token = None self.active_sessions = {} - + async def initialize(self): web_auth = self.config_manager.load_web_auth_config() - + if not web_auth.get("users"): default_username = "admin" default_password = "admin" - + salt = secrets.token_hex(8) hashed_password = self._hash_password(default_password, salt) - - web_auth["users"] = [{ - "username": default_username, - "password_hash": hashed_password, - "salt": salt, - "is_admin": True - }] - + + web_auth["users"] = [ + {"username": default_username, "password_hash": hashed_password, "salt": salt, "is_admin": True} + ] + await self.config_manager.save_web_auth_config(web_auth) logger.info("Default account created: admin/admin") - - def _hash_password(self, password: str, salt: str) -> str: + + @staticmethod + def _hash_password(password: str, salt: str) -> str: """Use SHA-256 and salt to hash password""" return hashlib.sha256((password + salt).encode()).hexdigest() - - def _generate_session_token(self) -> str: + + @staticmethod + def _generate_session_token() -> str: """Generate session token""" return secrets.token_hex(16) - + async def authenticate(self, username: str, password: str) -> tuple[bool, Optional[str]]: web_auth = self.config_manager.load_web_auth_config() users = web_auth.get("users", []) - + for user in users: if user["username"] == username: salt = user["salt"] hashed_password = self._hash_password(password, salt) - + if hashed_password == user["password_hash"]: session_token = self._generate_session_token() self.active_sessions[session_token] = { "username": username, - "is_admin": user.get("is_admin", False) + "is_admin": user.get("is_admin", False), } return True, session_token - + return False, None - + def validate_session(self, session_token: str) -> bool: """Validate session token""" return session_token in self.active_sessions - + def logout(self, session_token: str) -> bool: if session_token in self.active_sessions: del self.active_sessions[session_token] return True return False - + async def change_password(self, username: str, old_password: str, new_password: str) -> bool: web_auth = self.config_manager.load_web_auth_config() users = web_auth.get("users", []) - + for i, user in enumerate(users): if user["username"] == username: salt = user["salt"] hashed_old_password = self._hash_password(old_password, salt) - + if hashed_old_password == user["password_hash"]: new_salt = secrets.token_hex(8) hashed_new_password = self._hash_password(new_password, new_salt) - + web_auth["users"][i]["password_hash"] = hashed_new_password web_auth["users"][i]["salt"] = new_salt - + await self.config_manager.save_web_auth_config(web_auth) return True - - return False \ No newline at end of file + + return False diff --git a/app/core/config/language_manager.py b/app/core/config/language_manager.py index 9890a32..9d9afa2 100644 --- a/app/core/config/language_manager.py +++ b/app/core/config/language_manager.py @@ -28,13 +28,17 @@ class LanguageManager: def add_observer(self, observer): """Add an observer that will be notified when the language changes.""" - if observer not in self._observers: - self._observers.append(observer) + for observer in self._observers: + if id(observer) == id(observer): + return + self._observers.append(observer) def remove_observer(self, observer): """Remove an observer.""" - if observer in self._observers: - self._observers.remove(observer) + for observer in self._observers: + if id(observer) == id(observer): + return + self._observers.remove(observer) def notify_observers(self): """Notify all observers that the language has changed.""" diff --git a/app/core/media/direct_downloader.py b/app/core/media/direct_downloader.py index 7c96b9a..1c2f9be 100644 --- a/app/core/media/direct_downloader.py +++ b/app/core/media/direct_downloader.py @@ -13,12 +13,14 @@ class DirectStreamDownloader: Directly download the live stream using HTTP requests, used to handle FLV streams that ffmpeg cannot handle normally """ - def __init__(self, - record_url: str, - save_path: str, - headers: Optional[dict[str, str]] = None, - proxy: Optional[str] = None, - chunk_size: int = 1024 * 16): # 16KB chunks + def __init__( + self, + record_url: str, + save_path: str, + headers: Optional[dict[str, str]] = None, + proxy: Optional[str] = None, + chunk_size: int = 1024 * 16, + ): # 16KB chunks self.record_url = record_url self.save_path = save_path self.headers = headers or {} @@ -56,7 +58,7 @@ class DirectStreamDownloader: logger.error(f"Request Stream Failed, Status Code: {response.status_code}") return - with open(self.save_path, 'wb') as f: + with open(self.save_path, "wb") as f: async for chunk in response.aiter_bytes(self.chunk_size): if self.stop_event.is_set(): break diff --git a/app/core/media/ffmpeg_builders/audio/aac.py b/app/core/media/ffmpeg_builders/audio/aac.py index 3f4690b..9d3c239 100644 --- a/app/core/media/ffmpeg_builders/audio/aac.py +++ b/app/core/media/ffmpeg_builders/audio/aac.py @@ -5,6 +5,7 @@ class AACCommandBuilder(FFmpegCommandBuilder): def build_command(self) -> list[str]: command = self._get_basic_ffmpeg_command() + # fmt: off if self.segment_record: additional_commands = [ "-c:a", "aac", @@ -25,6 +26,6 @@ class AACCommandBuilder(FFmpegCommandBuilder): "-f", "ipod", self.full_path, ] - + # fmt: on command.extend(additional_commands) return command diff --git a/app/core/media/ffmpeg_builders/audio/m4a.py b/app/core/media/ffmpeg_builders/audio/m4a.py index c0d5a03..814e0ae 100644 --- a/app/core/media/ffmpeg_builders/audio/m4a.py +++ b/app/core/media/ffmpeg_builders/audio/m4a.py @@ -5,6 +5,7 @@ class M4ACommandBuilder(FFmpegCommandBuilder): def build_command(self) -> list[str]: command = self._get_basic_ffmpeg_command() + # fmt: off if self.segment_record: additional_commands = [ "-c:a", "aac", @@ -23,6 +24,7 @@ class M4ACommandBuilder(FFmpegCommandBuilder): "-f", "mp4", self.full_path, ] + # fmt: on command.extend(additional_commands) return command diff --git a/app/core/media/ffmpeg_builders/audio/mp3.py b/app/core/media/ffmpeg_builders/audio/mp3.py index 2e2f075..5f2a088 100644 --- a/app/core/media/ffmpeg_builders/audio/mp3.py +++ b/app/core/media/ffmpeg_builders/audio/mp3.py @@ -5,6 +5,7 @@ class MP3CommandBuilder(FFmpegCommandBuilder): def build_command(self) -> list[str]: command = self._get_basic_ffmpeg_command() + # fmt: off if self.segment_record: additional_commands = [ "-c:a", "libmp3lame", @@ -24,5 +25,6 @@ class MP3CommandBuilder(FFmpegCommandBuilder): self.full_path, ] + # fmt: on command.extend(additional_commands) return command diff --git a/app/core/media/ffmpeg_builders/audio/wav.py b/app/core/media/ffmpeg_builders/audio/wav.py index b113a30..4c406c5 100644 --- a/app/core/media/ffmpeg_builders/audio/wav.py +++ b/app/core/media/ffmpeg_builders/audio/wav.py @@ -5,6 +5,7 @@ class WAVCommandBuilder(FFmpegCommandBuilder): def build_command(self) -> list[str]: command = self._get_basic_ffmpeg_command() + # fmt: off if self.segment_record: additional_commands = [ "-c:a", "pcm_s16le", @@ -26,5 +27,6 @@ class WAVCommandBuilder(FFmpegCommandBuilder): self.full_path, ] + # fmt: on command.extend(additional_commands) return command diff --git a/app/core/media/ffmpeg_builders/audio/wma.py b/app/core/media/ffmpeg_builders/audio/wma.py index c17c4fa..128ac85 100644 --- a/app/core/media/ffmpeg_builders/audio/wma.py +++ b/app/core/media/ffmpeg_builders/audio/wma.py @@ -5,6 +5,7 @@ class WMACommandBuilder(FFmpegCommandBuilder): def build_command(self) -> list[str]: command = self._get_basic_ffmpeg_command() + # fmt: off if self.segment_record: additional_commands = [ "-c:a", "wmav2", @@ -25,6 +26,7 @@ class WMACommandBuilder(FFmpegCommandBuilder): "-f", "asf", self.full_path, ] + # fmt: on command.extend(additional_commands) - return command \ No newline at end of file + return command diff --git a/app/core/media/ffmpeg_builders/base.py b/app/core/media/ffmpeg_builders/base.py index cf008ab..ee0d21f 100644 --- a/app/core/media/ffmpeg_builders/base.py +++ b/app/core/media/ffmpeg_builders/base.py @@ -67,6 +67,7 @@ class FFmpegCommandBuilder(abc.ABC): :return: List of strings representing the FFmpeg command components. """ config = OVERSEAS_CONFIG if self.is_overseas else DEFAULT_CONFIG + # fmt: off command = [ "ffmpeg", "-y", @@ -93,6 +94,7 @@ class FFmpegCommandBuilder(abc.ABC): "-avoid_negative_ts", "1", "-flush_packets", "1" ] + # fmt: on if self.headers: command.insert(11, "-headers") diff --git a/app/core/media/ffmpeg_builders/video/flv.py b/app/core/media/ffmpeg_builders/video/flv.py index 9b7d2b0..906dbd9 100644 --- a/app/core/media/ffmpeg_builders/video/flv.py +++ b/app/core/media/ffmpeg_builders/video/flv.py @@ -4,6 +4,7 @@ from ..base import FFmpegCommandBuilder class FLVCommandBuilder(FFmpegCommandBuilder): def build_command(self) -> list[str]: command = self._get_basic_ffmpeg_command() + # fmt: off if self.segment_record: additional_commands = [ "-map", "0", @@ -25,5 +26,6 @@ class FLVCommandBuilder(FFmpegCommandBuilder): "-f", "flv", self.full_path ] + # fmt: on command.extend(additional_commands) return command diff --git a/app/core/media/ffmpeg_builders/video/mkv.py b/app/core/media/ffmpeg_builders/video/mkv.py index 4b527e8..74c3724 100644 --- a/app/core/media/ffmpeg_builders/video/mkv.py +++ b/app/core/media/ffmpeg_builders/video/mkv.py @@ -4,6 +4,7 @@ from ..base import FFmpegCommandBuilder class MKVCommandBuilder(FFmpegCommandBuilder): def build_command(self) -> list[str]: command = self._get_basic_ffmpeg_command() + # fmt: off if self.segment_record: additional_commands = [ "-flags", "global_header", @@ -25,6 +26,7 @@ class MKVCommandBuilder(FFmpegCommandBuilder): "-f", "matroska", self.full_path, ] + # fmt: on command.extend(additional_commands) return command diff --git a/app/core/media/ffmpeg_builders/video/mov.py b/app/core/media/ffmpeg_builders/video/mov.py index 5e7b23a..4c74097 100644 --- a/app/core/media/ffmpeg_builders/video/mov.py +++ b/app/core/media/ffmpeg_builders/video/mov.py @@ -5,6 +5,7 @@ class MOVCommandBuilder(FFmpegCommandBuilder): def build_command(self) -> list[str]: command = self._get_basic_ffmpeg_command() + # fmt: off if self.segment_record: additional_commands = [ "-c:v", "copy", @@ -27,6 +28,7 @@ class MOVCommandBuilder(FFmpegCommandBuilder): "-movflags", "+faststart", self.full_path, ] + # fmt: on command.extend(additional_commands) return command diff --git a/app/core/media/ffmpeg_builders/video/mp4.py b/app/core/media/ffmpeg_builders/video/mp4.py index 4675abb..a8a563a 100644 --- a/app/core/media/ffmpeg_builders/video/mp4.py +++ b/app/core/media/ffmpeg_builders/video/mp4.py @@ -4,6 +4,7 @@ from ..base import FFmpegCommandBuilder class MP4CommandBuilder(FFmpegCommandBuilder): def build_command(self) -> list[str]: command = self._get_basic_ffmpeg_command() + # fmt: off if self.segment_record: additional_commands = [ "-c:v", "copy", @@ -26,6 +27,7 @@ class MP4CommandBuilder(FFmpegCommandBuilder): "-movflags", "+faststart+frag_keyframe+empty_moov+delay_moov", self.full_path, ] + # fmt: on command.extend(additional_commands) return command diff --git a/app/core/media/ffmpeg_builders/video/nut.py b/app/core/media/ffmpeg_builders/video/nut.py index c5912ca..ec14ac2 100644 --- a/app/core/media/ffmpeg_builders/video/nut.py +++ b/app/core/media/ffmpeg_builders/video/nut.py @@ -4,6 +4,7 @@ from ..base import FFmpegCommandBuilder class NUTCommandBuilder(FFmpegCommandBuilder): def build_command(self) -> list[str]: command = self._get_basic_ffmpeg_command() + # fmt: off if self.segment_record: additional_commands = [ "-c:v", "copy", @@ -28,5 +29,6 @@ class NUTCommandBuilder(FFmpegCommandBuilder): self.full_path, ] + # fmt: on command.extend(additional_commands) return command diff --git a/app/core/media/ffmpeg_builders/video/ts.py b/app/core/media/ffmpeg_builders/video/ts.py index e3f036a..d033316 100644 --- a/app/core/media/ffmpeg_builders/video/ts.py +++ b/app/core/media/ffmpeg_builders/video/ts.py @@ -4,6 +4,7 @@ from ..base import FFmpegCommandBuilder class TSCommandBuilder(FFmpegCommandBuilder): def build_command(self) -> list[str]: command = self._get_basic_ffmpeg_command() + # fmt: off if self.segment_record: additional_commands = [ "-c:v", "copy", @@ -29,6 +30,6 @@ class TSCommandBuilder(FFmpegCommandBuilder): "-muxpreload", "0", self.full_path, ] - + # fmt: on command.extend(additional_commands) return command diff --git a/app/core/platforms/platform_handlers/__init__.py b/app/core/platforms/platform_handlers/__init__.py index b3bf5a3..1e243dd 100644 --- a/app/core/platforms/platform_handlers/__init__.py +++ b/app/core/platforms/platform_handlers/__init__.py @@ -88,7 +88,7 @@ def get_platform_info(record_url: str) -> tuple: "https://www.bigo.tv/": ("Bigo直播", "bigo"), "https://app.blued.cn/": ("Blued直播", "blued"), "sooplive.co.kr/": ("SOOP", "sooplive"), - "www.sooplive.com/": ("SOOP", "sooplive"), + "sooplive.com/": ("SOOP", "sooplive"), "cc.163.com/": ("网易CC直播", "netease"), "qiandurebo.com/": ("千度热播", "qiandurebo"), "pandalive.co.kr/": ("PandaTV", "pandalive"), diff --git a/app/core/platforms/platform_handlers/base.py b/app/core/platforms/platform_handlers/base.py index 6a1ca33..6aed2b4 100644 --- a/app/core/platforms/platform_handlers/base.py +++ b/app/core/platforms/platform_handlers/base.py @@ -60,8 +60,14 @@ class PlatformHandler(abc.ABC): @classmethod def _get_instance_key( - cls, proxy: str | None, cookies: str | None, record_quality: str, platform: str | None, - username: str | None = None, password: str | None = None, account_type: str | None = None + cls, + proxy: str | None, + cookies: str | None, + record_quality: str, + platform: str | None, + username: str | None = None, + password: str | None = None, + account_type: str | None = None, ) -> InstanceKey: """ Generate a unique key for each instance based on the provided parameters. diff --git a/app/core/platforms/platform_handlers/handlers.py b/app/core/platforms/platform_handlers/handlers.py index e07962d..1d9edbb 100644 --- a/app/core/platforms/platform_handlers/handlers.py +++ b/app/core/platforms/platform_handlers/handlers.py @@ -8,11 +8,11 @@ class CustomHandler(PlatformHandler): platform = "custom" def __init__( - self, - proxy: str | None = None, - cookies: str | None = None, - record_quality: str | None = None, - platform: str | None = None, + self, + proxy: str | None = None, + cookies: str | None = None, + record_quality: str | None = None, + platform: str | None = None, ) -> None: super().__init__(proxy, cookies, record_quality, platform) @@ -844,7 +844,7 @@ class PiaopiaoHandler(PlatformHandler): if not self.live_stream: self.live_stream = streamget.PiaopaioLiveStream(proxy_addr=self.proxy, cookies=self.cookies) - if 'preview.html' not in live_url: + if "preview.html" not in live_url: json_data = await self.live_stream.fetch_app_stream_data(url=live_url) else: json_data = await self.live_stream.fetch_web_stream_data(url=live_url) diff --git a/app/core/recording/record_manager.py b/app/core/recording/record_manager.py index 07baa6c..e6ff344 100644 --- a/app/core/recording/record_manager.py +++ b/app/core/recording/record_manager.py @@ -90,7 +90,7 @@ class RecordingManager: @staticmethod async def _update_recording( - recording: Recording, monitor_status: bool, display_title: str, status_info: str, selected: bool + recording: Recording, monitor_status: bool, display_title: str, status_info: str, selected: bool ): attrs_update = { "monitor_status": monitor_status, @@ -257,7 +257,8 @@ class RecordingManager: if recording.scheduled_recording: scheduled_time_range_list = await self.get_scheduled_time_range( - recording.scheduled_start_time, recording.monitor_hours) + recording.scheduled_start_time, recording.monitor_hours + ) recording.scheduled_time_range = scheduled_time_range_list in_scheduled = False for scheduled_time_range in scheduled_time_range_list: @@ -332,28 +333,31 @@ class RecordingManager: if desktop_notify.should_push_notification(self.app): desktop_notify.send_notification( title=self._["notify"], - message=recording.streamer_name + ' | ' + self._["live_recording_started_message"], - app_icon=self.app.tray_manager.icon_path + message=recording.streamer_name + " | " + self._["live_recording_started_message"], + app_icon=self.app.tray_manager.icon_path, ) msg_manager = message_pusher.MessagePusher(self.settings) user_config = self.settings.user_config - if (msg_manager.should_push_message(self.settings, recording, message_type='start') - and not recording.notified_live_start): + if ( + msg_manager.should_push_message(self.settings, recording, message_type="start") + and not recording.notified_live_start + ): push_content = self._["push_content"] begin_push_message_text = user_config.get("custom_stream_start_content") if begin_push_message_text: push_content = begin_push_message_text push_at = datetime.today().strftime("%Y-%m-%d %H:%M:%S") - push_content = push_content.replace("[room_name]", recording.streamer_name).replace( - "[time]", push_at).replace("[title]", recording.live_title or "None") + push_content = ( + push_content.replace("[room_name]", recording.streamer_name) + .replace("[time]", push_at) + .replace("[title]", recording.live_title or "None") + ) msg_title = user_config.get("custom_notification_title").strip() msg_title = msg_title or self._["status_notify"] - BackgroundService.get_instance().add_task( - msg_manager.push_messages_sync, msg_title, push_content - ) + BackgroundService.get_instance().add_task(msg_manager.push_messages_sync, msg_title, push_content) recording.notified_live_start = True if not recording.only_notify_no_record: @@ -380,8 +384,7 @@ class RecordingManager: recording.status_info = RecordingStatus.MONITORING title = f"{stream_info.anchor_name or recording.streamer_name} - {self._[recording.quality]}" - if recording.streamer_name == self._["live_room"] or \ - f"[{self._['is_live']}]" in recording.display_title: + if recording.streamer_name == self._["live_room"] or f"[{self._['is_live']}]" in recording.display_title: recording.update( { "streamer_name": stream_info.anchor_name, @@ -415,7 +418,6 @@ class RecordingManager: """Stop the recording process.""" recording.is_live = False if recording.is_recording: - recording.stopping_in_progress = True logger.info(f"Trying to stop recorder for {recording.rec_id}, title: {recording.title}") @@ -459,11 +461,11 @@ class RecordingManager: async def delete_recording_cards(self, recordings: list[Recording]): self.app.page.run_task(self.app.record_card_manager.remove_recording_card, recordings) - self.app.page.pubsub.send_others_on_topic('delete', recordings) + self.app.page.pubsub.send_others_on_topic("delete", recordings) await self.remove_recordings(recordings) # update the filter area of the recording list page - if hasattr(self.app, 'current_page') and hasattr(self.app.current_page, 'content_area'): + if hasattr(self.app, "current_page") and hasattr(self.app.current_page, "content_area"): if len(self.app.current_page.content_area.controls) > 1: self.app.current_page.content_area.controls[1] = self.app.current_page.create_filter_area() self.app.current_page.content_area.update() @@ -473,14 +475,9 @@ class RecordingManager: output_dir = output_dir or self.settings.get_video_save_path() if utils.check_disk_capacity(output_dir) < disk_space_limit: self.app.recording_enabled = False - logger.error( - f"Disk space remaining is below {disk_space_limit} GB. Recording function disabled" - ) + logger.error(f"Disk space remaining is below {disk_space_limit} GB. Recording function disabled") self.app.page.run_task( - self.app.snack_bar.show_snack_bar, - self._["not_disk_space_tip"], - duration=86400, - show_close_icon=True + self.app.snack_bar.show_snack_bar, self._["not_disk_space_tip"], duration=86400, show_close_icon=True ) else: @@ -489,9 +486,9 @@ class RecordingManager: @staticmethod async def get_scheduled_time_range(scheduled_start_time, monitor_hours) -> list | None: scheduled_time_range_list = [] - for index, start_time in enumerate(scheduled_start_time.split(',')): + for index, start_time in enumerate(scheduled_start_time.split(",")): try: - hours = str(monitor_hours).split(',')[index] + hours = str(monitor_hours).split(",")[index] if start_time and hours: end_time = utils.add_hours_to_time(start_time, float(hours or 5)) scheduled_time_range = f"{start_time}~{end_time}" diff --git a/app/core/recording/stream_manager.py b/app/core/recording/stream_manager.py index 6e1dd17..16fb1ce 100644 --- a/app/core/recording/stream_manager.py +++ b/app/core/recording/stream_manager.py @@ -151,12 +151,9 @@ class LiveStreamRecorder: return self.platform_key in {"douyin", "tiktok"} def _select_source_url(self, stream_info: StreamData): - if ( - self.user_config.get("default_live_source") != "HLS" - and self.is_flv_preferred_platform - ): + if self.user_config.get("default_live_source") != "HLS" and self.is_flv_preferred_platform: codec = utils.get_query_params(stream_info.flv_url, "codec") - if codec and codec[0] == 'h265': + if codec and codec[0] == "h265": logger.warning("FLV is not supported for h265 codec, use HLS source instead") else: return stream_info.flv_url @@ -189,7 +186,7 @@ class LiveStreamRecorder: elif self.save_format == "flv": codec = utils.get_query_params(stream_info.flv_url, "codec") - if codec and codec[0] == 'h265': + if codec and codec[0] == "h265": logger.warning("FLV is not supported for h265 codec, use TS format instead") self.save_format = "ts" @@ -207,7 +204,7 @@ class LiveStreamRecorder: platform=self.platform, username=self.account_config.get(self.platform_key, {}).get("username"), password=self.account_config.get(self.platform_key, {}).get("password"), - account_type=self.account_config.get(self.platform_key, {}).get("account_type") + account_type=self.account_config.get(self.platform_key, {}).get("account_type"), ) stream_info = await handler.get_stream_info(self.live_url) @@ -238,7 +235,7 @@ class LiveStreamRecorder: old_recorder.request_stop() await asyncio.sleep(1) - + self.app.record_manager.active_recorders[self.recording.rec_id] = self logger.info(f"Saved recorder instance for {self.recording.rec_id}, id: {id(self)}") except Exception as e: @@ -253,10 +250,7 @@ class LiveStreamRecorder: headers[key] = value self.direct_downloader = DirectStreamDownloader( - record_url=record_url, - save_path=save_path, - headers=headers, - proxy=self.proxy + record_url=record_url, save_path=save_path, headers=headers, proxy=self.proxy ) self.app.page.run_task( @@ -266,7 +260,7 @@ class LiveStreamRecorder: record_url, save_path, self.save_format, - self.user_config.get("custom_script_command") + self.user_config.get("custom_script_command"), ) else: ffmpeg_builder = ffmpeg_builders.create_builder( @@ -276,7 +270,7 @@ class LiveStreamRecorder: segment_record=self.segment_record, segment_time=self.segment_time, full_path=save_path, - headers=self.get_headers_params(record_url, self.platform_key) + headers=self.get_headers_params(record_url, self.platform_key), ) ffmpeg_command = ffmpeg_builder.build_command() self.app.page.run_task( @@ -286,7 +280,7 @@ class LiveStreamRecorder: record_url, ffmpeg_command, self.save_format, - self.user_config.get("custom_script_command") + self.user_config.get("custom_script_command"), ) async def remove_active_recorder(self): @@ -308,13 +302,13 @@ class LiveStreamRecorder: self.recording.status_info = RecordingStatus.RECORDING_ERROR async def start_ffmpeg( - self, - record_name: str, - live_url: str, - record_url: str, - ffmpeg_command: list, - save_type: str, - script_command: str | None = None + self, + record_name: str, + live_url: str, + record_url: str, + ffmpeg_command: list, + save_type: str, + script_command: str | None = None, ) -> bool: """ The child process executes ffmpeg for recording @@ -331,7 +325,7 @@ class LiveStreamRecorder: stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, - startupinfo=self.subprocess_start_info + startupinfo=self.subprocess_start_info, ) self.app.add_ffmpeg_process(process) @@ -354,6 +348,7 @@ class LiveStreamRecorder: await asyncio.sleep(5) else: import signal + process.send_signal(signal.SIGINT) # process.terminate() await asyncio.sleep(5) @@ -381,7 +376,7 @@ class LiveStreamRecorder: return_code = process.returncode safe_return_code = [0, 255] stdout, stderr = await process.communicate() - + if return_code not in safe_return_code and stderr: if not self.recording.is_recording: logger.error(f"FFmpeg Stderr Output: {str(stderr.decode()).splitlines()[0]}") @@ -413,7 +408,7 @@ class LiveStreamRecorder: else: logger.success(f"Live recording completed: {record_name}") self.app.page.run_task(self.end_message_push) - + try: self.recording.update({"display_title": display_title}) self.app.page.run_task(self.app.record_card_manager.update_card, self.recording) @@ -425,7 +420,8 @@ class LiveStreamRecorder: self.recording.status_info = RecordingStatus.NOT_RECORDING_SPACE self.app.page.run_task(self.stop_recording_notify) - await self.recheck_live_status() + if not self.recording.manually_stopped: + await self.recheck_live_status() if self.user_config.get("convert_to_mp4") and self.save_format == "ts": if self.segment_record: @@ -434,9 +430,7 @@ class LiveStreamRecorder: for path in file_paths: if prefix in path: try: - self.app.page.run_task( - self.converts_mp4, path, self.user_config["delete_original"] - ) + self.app.page.run_task(self.converts_mp4, path, self.user_config["delete_original"]) except Exception as e: logger.error(f"Failed to convert video: {e}") await self.converts_mp4(path, self.user_config["delete_original"]) @@ -459,7 +453,7 @@ class LiveStreamRecorder: save_file_path, save_type, self.segment_record, - self.user_config.get("convert_to_mp4") + self.user_config.get("convert_to_mp4"), ) logger.success("Successfully added script execution") except Exception as e: @@ -470,7 +464,7 @@ class LiveStreamRecorder: save_file_path, save_type, self.segment_record, - self.user_config.get("convert_to_mp4") + self.user_config.get("convert_to_mp4"), ) except Exception as e: @@ -481,9 +475,7 @@ class LiveStreamRecorder: self.app.record_manager.stop_recording(self.recording) await self.app.record_card_manager.update_card(self.recording) self.app.page.pubsub.send_others_on_topic("update", self.recording) - await self.app.snack_bar.show_snack_bar( - record_name + " " + self._["no_ffmpeg_tip"], duration=4000 - ) + await self.app.snack_bar.show_snack_bar(record_name + " " + self._["no_ffmpeg_tip"], duration=4000) except Exception as e: logger.debug(f"Failed to update UI: {e}") return False @@ -496,9 +488,7 @@ class LiveStreamRecorder: """Asynchronous transcoding method, can be added to the background service to continue execution""" if not self.app.recording_enabled: logger.info(f"Application is closing, adding transcoding task to background service: {converts_file_path}") - BackgroundService.get_instance().add_task( - self.converts_mp4_sync, converts_file_path, is_original_delete - ) + BackgroundService.get_instance().add_task(self.converts_mp4_sync, converts_file_path, is_original_delete) return # Otherwise, execute transcoding normally @@ -523,18 +513,22 @@ class LiveStreamRecorder: save_path = converts_file_path.rsplit(".", maxsplit=1)[0] + ".mp4" ffmpeg_command = [ "ffmpeg", - "-i", converts_file_path, - "-c:v", "copy", - "-c:a", "copy", - "-f", "mp4", - save_path + "-i", + converts_file_path, + "-c:v", + "copy", + "-c:a", + "copy", + "-f", + "mp4", + save_path, ] process = await asyncio.create_subprocess_exec( *ffmpeg_command, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, - startupinfo=self.subprocess_start_info + startupinfo=self.subprocess_start_info, ) self.app.add_ffmpeg_process(process) @@ -545,7 +539,8 @@ class LiveStreamRecorder: logger.info(f"Video transcoding completed: {save_path}") else: logger.error( - f"Video transcoding failed! Error message: {stderr.decode() if stderr else 'Unknown error'}") + f"Video transcoding failed! Error message: {stderr.decode() if stderr else 'Unknown error'}" + ) except subprocess.CalledProcessError as e: logger.error(f"Video transcoding failed! Error message: {e.output.decode()}") @@ -569,13 +564,13 @@ class LiveStreamRecorder: logger.error(f"An unknown error occurred: {e}") async def custom_script_execute( - self, - script_command: str, - record_name: str, - save_file_path: str, - save_type: str, - split_video_by_time: bool, - converts_to_mp4: bool + self, + script_command: str, + record_name: str, + save_file_path: str, + save_type: str, + split_video_by_time: bool, + converts_to_mp4: bool, ): from ..runtime.process_manager import BackgroundService @@ -583,9 +578,9 @@ class LiveStreamRecorder: params = [ f'--record_name "{record_name}"', f'--save_file_path "{save_file_path}"', - f'--save_type {save_type}', - f'--split_video_by_time {split_video_by_time}', - f'--converts_to_mp4 {converts_to_mp4}', + f"--save_type {save_type}", + f"--split_video_by_time {split_video_by_time}", + f"--converts_to_mp4 {converts_to_mp4}", ] else: params = [ @@ -593,7 +588,7 @@ class LiveStreamRecorder: f'"{save_file_path}"', save_type, f"split_video_by_time: {split_video_by_time}", - f"converts_to_mp4: {converts_to_mp4}" + f"converts_to_mp4: {converts_to_mp4}", ] script_command = script_command.strip() + " " + " ".join(params) @@ -621,7 +616,7 @@ class LiveStreamRecorder: stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, startupinfo=self.subprocess_start_info, - text=False + text=False, ) stdout, stderr = await process.communicate() @@ -661,21 +656,21 @@ class LiveStreamRecorder: return record_headers.get(platform_key) async def start_direct_download( - self, - record_name: str, - live_url: str, - record_url: str, - save_file_path: str, - save_type: str, - script_command: str | None = None + self, + record_name: str, + live_url: str, + record_url: str, + save_file_path: str, + save_type: str, + script_command: str | None = None, ) -> bool: """ Use the direct downloader to download the live stream """ - + logger.info(f"Starting direct download - recorder id: {id(self)}, rec_id: {self.recording.rec_id}") self.should_stop = False - + try: await self.direct_downloader.start_download() @@ -735,24 +730,13 @@ class LiveStreamRecorder: logger.info("Prepare to execute custom script in the background") try: self.app.page.run_task( - self.custom_script_execute, - script_command, - record_name, - save_file_path, - save_type, - False, - False + self.custom_script_execute, script_command, record_name, save_file_path, save_type, False, False ) logger.success("Successfully added script execution") except Exception as e: logger.error(f"Failed to execute custom script: {e}") await self.custom_script_execute( - script_command, - record_name, - save_file_path, - save_type, - False, - False + script_command, record_name, save_file_path, save_type, False, False ) return True @@ -778,17 +762,21 @@ class LiveStreamRecorder: if desktop_notify.should_push_notification(self.app): desktop_notify.send_notification( title=self._["notify"], - message=self.recording.streamer_name + ' | ' + self._["live_recording_stopped_message"], - app_icon=self.app.tray_manager.icon_path + message=self.recording.streamer_name + " | " + self._["live_recording_stopped_message"], + app_icon=self.app.tray_manager.icon_path, ) async def end_message_push(self): msg_manager = message_pusher.MessagePusher(self.settings) user_config = self.settings.user_config - if (self.app.recording_enabled and msg_manager.should_push_message( - self.settings, self.recording, check_manually_stopped=True, message_type='end') and - not self.recording.notified_live_end): + if ( + self.app.recording_enabled + and msg_manager.should_push_message( + self.settings, self.recording, check_manually_stopped=True, message_type="end" + ) + and not self.recording.notified_live_end + ): self.recording.notified_live_end = True push_content = self._["push_content_end"] end_push_message_text = user_config.get("custom_stream_end_content") @@ -796,8 +784,11 @@ class LiveStreamRecorder: push_content = end_push_message_text push_at = datetime.today().strftime("%Y-%m-%d %H:%M:%S") - push_content = push_content.replace("[room_name]", self.recording.streamer_name).replace( - "[time]", push_at).replace("[title]", self.recording.live_title or "None") + push_content = ( + push_content.replace("[room_name]", self.recording.streamer_name) + .replace("[time]", push_at) + .replace("[title]", self.recording.live_title or "None") + ) msg_title = user_config.get("custom_notification_title").strip() msg_title = msg_title or self._["status_notify"] @@ -806,8 +797,8 @@ class LiveStreamRecorder: def request_stop(self): logger.info(f"Stop requested for recorder: {self.recording.url}, rec_id: {self.recording.rec_id}") logger.info(f"Recorder instance details - id: {id(self)}, recording: {self.recording.title}") - + old_value = self.should_stop self.should_stop = True - + logger.info(f"Set should_stop from {old_value} to {self.should_stop} for recorder: {self.recording.rec_id}") diff --git a/app/core/runtime/process_manager.py b/app/core/runtime/process_manager.py index 118ab11..2aa2d60 100644 --- a/app/core/runtime/process_manager.py +++ b/app/core/runtime/process_manager.py @@ -6,36 +6,35 @@ from ...utils.logger import logger class BackgroundService: - _instance = None - + @classmethod def get_instance(cls): if cls._instance is None: cls._instance = BackgroundService() return cls._instance - + def __init__(self): self.tasks = [] self.is_running = False self.worker_thread = None - + def add_task(self, task_func, *args, **kwargs): self.tasks.append((task_func, args, kwargs)) logger.info(f"Added background task: {task_func.__name__}") - + if not self.is_running: self.start() - + def start(self): if self.is_running: return - + self.is_running = True self.worker_thread = threading.Thread(target=self._process_tasks, daemon=False) self.worker_thread.start() logger.info("Background service started") - + def _process_tasks(self): while self.tasks: task_func, args, kwargs = self.tasks.pop(0) @@ -45,7 +44,7 @@ class BackgroundService: logger.info(f"Background task completed: {task_func.__name__}") except Exception as e: logger.error(f"Background task execution failed: {e}") - + logger.info("All background tasks completed, service stopped") self.is_running = False diff --git a/app/core/update/update_checker.py b/app/core/update/update_checker.py index 1fe27bd..f95ebc0 100644 --- a/app/core/update/update_checker.py +++ b/app/core/update/update_checker.py @@ -49,7 +49,7 @@ class UpdateChecker: self.app = app self.current_version = self._get_current_version() self.update_config = self._load_update_config() - + def _get_current_version(self) -> str: try: config_path = os.path.join(self.app.run_path, "config", "version.json") @@ -67,56 +67,54 @@ class UpdateChecker: github_repo = os.getenv("GITHUB_REPO", "ihmily/StreamCap") custom_api = os.getenv("CUSTOM_UPDATE_API", "") check_interval = int(os.getenv("UPDATE_CHECK_INTERVAL", "86400")) - + update_sources = [] - + if update_source in ["github", "both"]: - update_sources.append({ - "name": "GitHub", - "enabled": True, - "priority": 1 if update_source == "github" else 0, - "type": "github", - "repo": github_repo, - "url": "https://api.github.com/repos/" + github_repo + "/releases/latest", - "timeout": 10 - }) - + update_sources.append( + { + "name": "GitHub", + "enabled": True, + "priority": 1 if update_source == "github" else 0, + "type": "github", + "repo": github_repo, + "url": "https://api.github.com/repos/" + github_repo + "/releases/latest", + "timeout": 10, + } + ) + if update_source in ["custom", "both"] and custom_api: - update_sources.append({ - "name": "Custom", - "enabled": True, - "priority": 1 if update_source == "custom" else 2, - "type": "custom", - "repo": custom_api, - "url": custom_api, - "timeout": 5 - }) - - return { - "update_sources": update_sources, - "check_interval": check_interval, - "auto_check": auto_check - } - + update_sources.append( + { + "name": "Custom", + "enabled": True, + "priority": 1 if update_source == "custom" else 2, + "type": "custom", + "repo": custom_api, + "url": custom_api, + "timeout": 5, + } + ) + + return {"update_sources": update_sources, "check_interval": check_interval, "auto_check": auto_check} + async def check_for_updates(self) -> UpdateInfo: """Check for updates, prioritizing sources with higher priority""" sources = sorted( - [s for s in self.update_config["update_sources"] if s["enabled"]], - key=lambda x: x["priority"], - reverse=True + [s for s in self.update_config["update_sources"] if s["enabled"]], key=lambda x: x["priority"], reverse=True ) - + if not sources: logger.warning("No available update sources configured") return {"has_update": False, "error": "No available update sources configured"} - + tasks = [] for source in sources: if source["type"] == "github": tasks.append(self._check_github_update(source)) elif source["type"] == "custom": tasks.append(self._check_custom_update(source)) - + # Wait for any task to complete successfully or all to fail results = [] for task in asyncio.as_completed(tasks): @@ -128,22 +126,20 @@ class UpdateChecker: except Exception as e: logger.error(f"Update check failed: {e}") results.append({"has_update": False, "error": str(e)}) - + return results[-1] if results else {"has_update": False, "error": "All update sources check failed"} - + async def _check_github_update(self, source: UpdateSource) -> UpdateInfo: """Check for updates from GitHub""" try: timeout = httpx.Timeout(source["timeout"]) async with httpx.AsyncClient(timeout=timeout) as client: - response = await client.get( - "https://api.github.com/repos/" + source['repo'] + "/releases/latest" - ) + response = await client.get("https://api.github.com/repos/" + source["repo"] + "/releases/latest") if response.status_code == 200: latest_release = response.json() latest_version = latest_release["tag_name"].lstrip("v") - + if self._compare_versions(latest_version, self.current_version) > 0: download_urls = {} for asset in latest_release.get("assets", []): @@ -154,7 +150,7 @@ class UpdateChecker: download_urls["macos"] = asset["browser_download_url"] elif "linux" in name: download_urls["linux"] = latest_release["html_url"] - + return { "has_update": True, "latest_version": latest_version, @@ -162,16 +158,16 @@ class UpdateChecker: "release_notes": latest_release["body"], "download_url": latest_release["html_url"], "download_urls": download_urls, - "source": source["name"] + "source": source["name"], } return {"has_update": False, "source": source["name"]} except Exception as e: logger.error(f"Failed to check update from GitHub: {e}") return {"has_update": False, "error": str(e), "source": source["name"]} - + async def _check_custom_update(self, source: UpdateSource) -> UpdateInfo: """Check for updates from custom source - + Expected API Response Format: { "has_update": bool, # Whether there is a new version available @@ -189,20 +185,17 @@ class UpdateChecker: try: timeout = httpx.Timeout(source["timeout"]) async with httpx.AsyncClient(timeout=timeout) as client: - response = await client.get( - source["url"], - params={"current_version": self.current_version} - ) + response = await client.get(source["url"], params={"current_version": self.current_version}) if response.status_code == 200: update_info = response.json() if update_info.get("has_update", False): - return { - **update_info, - "source": source["name"] - } + return {**update_info, "source": source["name"]} return {"has_update": False, "source": source["name"]} - return {"has_update": False, "error": f"API returned status code: {response.status_code}", - "source": source["name"]} + return { + "has_update": False, + "error": f"API returned status code: {response.status_code}", + "source": source["name"], + } except Exception as e: logger.error(f"Failed to check update from custom source: {e}") return {"has_update": False, "error": str(e), "source": source["name"]} @@ -210,7 +203,7 @@ class UpdateChecker: @staticmethod def _compare_versions(version1: str, version2: str) -> int: """Compare version numbers, returns 1 if version1 > version2, 0 if equal, -1 if less""" - + def parse_version(version): if "-" in version: v_parts, pre_release = version.split("-", 1) @@ -224,7 +217,7 @@ class UpdateChecker: else: v_parts = version pre_release_value = 0 - + v_nums = [] for part in v_parts.split("."): try: @@ -237,12 +230,12 @@ class UpdateChecker: except ValueError: v_nums.append(0) break - + return v_nums, pre_release_value - + v1_parts, v1_pre = parse_version(version1) v2_parts, v2_pre = parse_version(version2) - + for i in range(max(len(v1_parts), len(v2_parts))): v1 = v1_parts[i] if i < len(v1_parts) else 0 v2 = v2_parts[i] if i < len(v2_parts) else 0 @@ -250,34 +243,39 @@ class UpdateChecker: return 1 elif v1 < v2: return -1 - + if v1_pre > v2_pre: return 1 elif v1_pre < v2_pre: return -1 - + return 0 - + async def show_update_dialog(self, update_info: dict[str, Any]) -> None: _ = self.app.language_manager.language.get("update", {}) dialog = ft.AlertDialog( title=ft.Text(_["new_version"].format(version=update_info.get("latest_version"))), - content=ft.Column([ - ft.Text(_["current_version"].format(version=update_info.get("current_version"))), - ft.Text(_["latest_version"].format(version=update_info.get("latest_version"))), - ft.Text(_["update_source"].format(source=update_info.get("source", _["unknown"]))), - ], spacing=10, width=400, height=300), + content=ft.Column( + [ + ft.Text(_["current_version"].format(version=update_info.get("current_version"))), + ft.Text(_["latest_version"].format(version=update_info.get("latest_version"))), + ft.Text(_["update_source"].format(source=update_info.get("source", _["unknown"]))), + ], + spacing=10, + width=400, + height=300, + ), actions=[ ft.TextButton(_["later"], on_click=lambda _: self.close_dialog()), - ft.TextButton(_["download"], on_click=lambda _: self.open_download_page(update_info)) + ft.TextButton(_["download"], on_click=lambda _: self.open_download_page(update_info)), ], ) self.app.dialog_area.content = dialog self.app.dialog_area.content.open = True self.app.dialog_area.update() - + def close_dialog(self) -> None: if self.app.dialog_area.content: self.app.dialog_area.content.open = False @@ -287,7 +285,7 @@ class UpdateChecker: import platform url = update_info.get("download_url", "https://github.com/ihmily/StreamCap/releases/latest") - + download_urls = update_info.get("download_urls", {}) if download_urls: system = platform.system().lower() @@ -297,6 +295,6 @@ class UpdateChecker: url = download_urls["macos"] elif system == "linux" and "linux" in download_urls: url = download_urls["linux"] - + self.app.page.launch_url(url) self.close_dialog() diff --git a/app/initialization/installation_manager.py b/app/initialization/installation_manager.py index c6aca46..abafd11 100644 --- a/app/initialization/installation_manager.py +++ b/app/initialization/installation_manager.py @@ -67,13 +67,15 @@ class InstallationManager: right_btn.icon = ft.Icons.ERROR_OUTLINED right_btn.style = ft.ButtonStyle( - color=ft.Colors.WHITE, bgcolor=ft.Colors.RED_400, icon_color=ft.Colors.RED_600) + color=ft.Colors.WHITE, bgcolor=ft.Colors.RED_400, icon_color=ft.Colors.RED_600 + ) else: left_btn.text = self._["installed"] right_btn.icon = ft.Icons.CHECK_CIRCLE_OUTLINED right_btn.style = ft.ButtonStyle( - color=ft.Colors.WHITE, bgcolor=ft.Colors.GREEN_400, icon_color=ft.Colors.GREEN_600) + color=ft.Colors.WHITE, bgcolor=ft.Colors.GREEN_400, icon_color=ft.Colors.GREEN_600 + ) self.page.update() async def update_component_progress(self, component_name, progress, status): @@ -101,8 +103,11 @@ class InstallationManager: status_text = ft.Text(f"{component['name']} - {self._['wait_install']}...", size=14, no_wrap=False) component_item = ft.Row( controls=[ - ft.Column([ft.Text(component["name"], size=16), status_text], - alignment=ft.MainAxisAlignment.START, expand=True), + ft.Column( + [ft.Text(component["name"], size=16), status_text], + alignment=ft.MainAxisAlignment.START, + expand=True, + ), ft.Column([progress_ring, ft.Text("0%", size=12)], horizontal_alignment=ft.CrossAxisAlignment.END), ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN, @@ -128,7 +133,7 @@ class InstallationManager: ft.Row( [ft.Checkbox(label=self._["dont_show_again"], value=False, on_change=self.on_dont_show_again)], alignment=ft.MainAxisAlignment.START, - ) + ), ], horizontal_alignment=ft.CrossAxisAlignment.CENTER, spacing=15, @@ -184,6 +189,6 @@ class InstallationManager: self.page.run_task(self.show_install_dialog) else: from ..scripts import ffmpeg_install, node_install + ffmpeg_install.update_env_path() node_install.update_env_path() - \ No newline at end of file diff --git a/app/lifecycle/app_close_handler.py b/app/lifecycle/app_close_handler.py index ec8e870..3ec40a6 100644 --- a/app/lifecycle/app_close_handler.py +++ b/app/lifecycle/app_close_handler.py @@ -1,4 +1,4 @@ -import asyncio +import os import threading import time @@ -8,18 +8,13 @@ from ..utils.logger import logger from .tray_manager import TrayManager -def _safe_destroy_window(page): +async def _safe_destroy_window(page): try: - page.update() - to_cancel = asyncio.all_tasks(page.loop) - if not to_cancel: - return - for task in to_cancel: - task.cancel() + await page.window.destroy() except Exception as ex: logger.error(f"close window error: {ex}") finally: - page.window.destroy() + os._exit(0) async def handle_app_close(page: ft.Page, app, save_progress_overlay) -> None: @@ -38,7 +33,7 @@ async def handle_app_close(page: ft.Page, app, save_progress_overlay) -> None: async def close_dialog_dismissed(e): app.recording_enabled = False - + app.settings.user_config["last_route"] = page.route await app.config_manager.save_user_config(app.settings.user_config) logger.info(f"Saved last route: {page.route}") @@ -48,8 +43,9 @@ async def handle_app_close(page: ft.Page, app, save_progress_overlay) -> None: active_recordings_count = len(active_recordings) if active_recordings_count > 0: - save_progress_overlay.show(_["saving_recordings"].format(active_recordings_count=active_recordings_count), - cancellable=True) + save_progress_overlay.show( + _["saving_recordings"].format(active_recordings_count=active_recordings_count), cancellable=True + ) page.update() def close_app(): @@ -57,7 +53,8 @@ async def handle_app_close(page: ft.Page, app, save_progress_overlay) -> None: # adjust wait time based on the number of recordings, at least 2 seconds base_wait_time = max(2, min(active_recordings_count, 10)) logger.info( - f"waiting for {active_recordings_count} recordings to finish, waiting {base_wait_time} seconds") + f"waiting for {active_recordings_count} recordings to finish, waiting {base_wait_time} seconds" + ) time.sleep(base_wait_time) @@ -74,13 +71,15 @@ async def handle_app_close(page: ft.Page, app, save_progress_overlay) -> None: finally: if not getattr(app, "is_web_mode", False) and hasattr(app, "tray_manager"): app.tray_manager.stop() - page.window.destroy() + page.run_task(page.window.destroy) + time.sleep(0.3) + os._exit(0) threading.Thread(target=close_app, daemon=True).start() else: if not getattr(app, "is_web_mode", False) and hasattr(app, "tray_manager"): app.tray_manager.stop() - _safe_destroy_window(page) + await _safe_destroy_window(page) await close_dialog(e) @@ -94,21 +93,21 @@ async def handle_app_close(page: ft.Page, app, save_progress_overlay) -> None: size=14, text_align=ft.TextAlign.CENTER, ), - ft.Container(height=10) + ft.Container(height=10), ] - if page.platform.value != 'macos': + if page.platform.value != "macos": close_confirm_controls.append( ft.Container( content=ft.Text( _["minimize_to_tray_tip"], size=12, - color=ft.colors.GREY_500, + color=ft.Colors.GREY_500, text_align=ft.TextAlign.CENTER, ), padding=ft.padding.all(8), border_radius=5, - bgcolor=ft.colors.with_opacity(0.1, ft.colors.BLUE_GREY), + bgcolor=ft.Colors.with_opacity(0.1, ft.Colors.BLUE_GREY), ) ) @@ -117,26 +116,28 @@ async def handle_app_close(page: ft.Page, app, save_progress_overlay) -> None: content=ft.Text(_["cancel"], size=14), on_click=close_dialog, style=ft.ButtonStyle( - color=ft.colors.PRIMARY, + color=ft.Colors.PRIMARY, ), ), ft.OutlinedButton( content=ft.Text(_["exit_program"], size=14), on_click=close_dialog_dismissed, style=ft.ButtonStyle( - color=ft.colors.ERROR, + color=ft.Colors.ERROR, ), ), ] - if page.platform.value != 'macos': + if page.platform.value != "macos": close_confirm_actions.insert( - 1, ft.TextButton( + 1, + ft.TextButton( content=ft.Text(_["minimize_to_tray"], size=14), on_click=minimize_to_tray, style=ft.ButtonStyle( - color=ft.colors.PRIMARY, + color=ft.Colors.PRIMARY, ), - )) + ), + ) close_confirm_dialog = ft.AlertDialog( modal=True, @@ -154,7 +155,7 @@ async def handle_app_close(page: ft.Page, app, save_progress_overlay) -> None: horizontal_alignment=ft.CrossAxisAlignment.CENTER, ), padding=ft.padding.symmetric(horizontal=20, vertical=10), - width=400 if page.platform.value != 'macos' else None, + width=400 if page.platform.value != "macos" else None, ), actions=close_confirm_actions, actions_alignment=ft.MainAxisAlignment.END, diff --git a/app/lifecycle/tray_manager.py b/app/lifecycle/tray_manager.py index 09e142e..1b2fb8f 100644 --- a/app/lifecycle/tray_manager.py +++ b/app/lifecycle/tray_manager.py @@ -7,7 +7,6 @@ from ..utils.logger import logger class TrayManager: - def __init__(self, app): self.app = app self.icon = None @@ -20,7 +19,7 @@ class TrayManager: def create_image(self): try: from PIL import Image - + self.icon_path = os.path.join(self.execute_dir, self.assets_dir, "icons", "tray_icon.ico") if os.path.exists(self.icon_path): return Image.open(self.icon_path) @@ -28,7 +27,8 @@ class TrayManager: logger.error(f"Failed to load icon file: {e}") try: from PIL import Image - image = Image.new('RGB', (32, 32), color=(255, 255, 255)) + + image = Image.new("RGB", (32, 32), color=(255, 255, 255)) return image except Exception as e: logger.error("PIL not available, unable to create tray icon") @@ -37,10 +37,10 @@ class TrayManager: def create_tray_icon(self, page: ft.Page): if self.is_running: return - + try: import pystray - + def on_restore(_icon, _item): page.window.visible = True page.window.minimized = False @@ -58,10 +58,7 @@ class TrayManager: for key in ("tray_manager", "base"): _.update(language.get(key, {})) - menu = pystray.Menu( - pystray.MenuItem(_["restore"], on_restore), - pystray.MenuItem(_["exit"], on_exit) - ) + menu = pystray.Menu(pystray.MenuItem(_["restore"], on_restore), pystray.MenuItem(_["exit"], on_exit)) self.icon = pystray.Icon("StreamCap", self.create_image(), "StreamCap", menu) self.is_running = True @@ -76,7 +73,7 @@ class TrayManager: if getattr(self.app, "is_web_mode", False): logger.info("Tray icon not available in web mode") return False - + if self.tray_thread is None or not self.tray_thread.is_alive(): self.tray_thread = threading.Thread(target=self.create_tray_icon, args=(page,), daemon=True) self.tray_thread.start() diff --git a/app/messages/desktop_notify.py b/app/messages/desktop_notify.py index 55575df..132eb71 100644 --- a/app/messages/desktop_notify.py +++ b/app/messages/desktop_notify.py @@ -1,14 +1,7 @@ - - def send_notification(title: str, message: str, app_icon: str = "", app_name: str = "StreamCap", timeout: int = 10): from plyer import notification - notification.notify( - title=title, - message=message, - app_icon=app_icon, - app_name=app_name, - timeout=timeout - ) + + notification.notify(title=title, message=message, app_icon=app_icon, app_name=app_name, timeout=timeout) def should_push_notification(app) -> bool: diff --git a/app/messages/message_pusher.py b/app/messages/message_pusher.py index ccc18fc..66ba7ff 100644 --- a/app/messages/message_pusher.py +++ b/app/messages/message_pusher.py @@ -36,44 +36,44 @@ class MessagePusher: @staticmethod def should_push_message( - settings: SettingsPage, - recording: Recording, - check_manually_stopped: bool = False, - message_type: Optional[str] = None + settings: SettingsPage, + recording: Recording, + check_manually_stopped: bool = False, + message_type: Optional[str] = None, ) -> bool: """ Check if message should be pushed """ if not recording.enabled_message_push: return False - + user_config = settings.user_config should_only_notify_no_record = user_config.get("only_notify_no_record") is_stream_start_enabled = user_config.get("stream_start_notification_enabled") is_stream_end_enabled = user_config.get("stream_end_notification_enabled") if message_type is None: - if hasattr(recording, 'is_recording') and recording.is_recording: - message_type = 'end' + if hasattr(recording, "is_recording") and recording.is_recording: + message_type = "end" else: - message_type = 'start' + message_type = "start" - if message_type == 'start' and should_only_notify_no_record and is_stream_start_enabled: + if message_type == "start" and should_only_notify_no_record and is_stream_start_enabled: return True - if message_type == 'start' and not is_stream_start_enabled: + if message_type == "start" and not is_stream_start_enabled: return False - - if message_type == 'end' and not is_stream_end_enabled: + + if message_type == "end" and not is_stream_end_enabled: return False push_channels = MessagePusher._get_push_channels() any_channel_enabled = any(user_config.get(channel) for channel in push_channels) - + if not any_channel_enabled: return False - - if message_type == 'end' and check_manually_stopped and recording.manually_stopped: + + if message_type == "end" and check_manually_stopped and recording.manually_stopped: return False return True diff --git a/app/messages/notification_service.py b/app/messages/notification_service.py index 0038d83..bec31aa 100644 --- a/app/messages/notification_service.py +++ b/app/messages/notification_service.py @@ -17,7 +17,6 @@ class NotificationService: async def _async_post(self, url: str, json_data: dict[str, Any], proxy: str | None = None) -> dict[str, Any]: try: - async with httpx.AsyncClient(proxy=proxy) as client: response = await client.post(url, json=json_data, headers=self.headers) response.raise_for_status() @@ -27,7 +26,7 @@ class NotificationService: return {"error": str(e)} async def send_to_dingtalk( - self, url: str, content: str, number: Optional[str] = None, is_atall: bool = False + self, url: str, content: str, number: Optional[str] = None, is_atall: bool = False ) -> dict[str, list[str]]: results = {"success": [], "error": []} api_list = [u.strip() for u in url.replace(",", ",").split(",") if u.strip()] @@ -59,16 +58,16 @@ class NotificationService: @staticmethod async def send_to_email( - email_host: str, - login_email: str, - password: str, - sender_email: str, - sender_name: str, - to_email: str, - title: str, - content: str, - smtp_port: str | None = None, - open_ssl: bool = True, + email_host: str, + login_email: str, + password: str, + sender_email: str, + sender_name: str, + to_email: str, + title: str, + content: str, + smtp_port: str | None = None, + open_ssl: bool = True, ) -> dict[str, Any]: receivers = to_email.replace(",", ",").split(",") if to_email.strip() else [] results = {"success": [], "error": []} @@ -99,7 +98,8 @@ class NotificationService: return results async def send_to_telegram( - self, chat_id: int, token: str, content: str, proxy: Optional[str] = None) -> dict[str, Any]: + self, chat_id: int, token: str, content: str, proxy: Optional[str] = None + ) -> dict[str, Any]: try: json_data = {"chat_id": chat_id, "text": content} url = "https://api.telegram.org/bot" + token + "/sendMessage" @@ -110,18 +110,18 @@ class NotificationService: return {"success": [], "error": [1]} async def send_to_bark( - self, - api: str, - title: str = "message", - content: str = "test", - level: str = "active", - badge: int = 1, - auto_copy: int = 1, - sound: str = "", - icon: str = "", - group: str = "", - is_archive: int = 1, - url: str = "", + self, + api: str, + title: str = "message", + content: str = "test", + level: str = "active", + badge: int = 1, + auto_copy: int = 1, + sound: str = "", + icon: str = "", + group: str = "", + is_archive: int = 1, + url: str = "", ) -> dict[str, Any]: results = {"success": [], "error": []} api_list = api.replace(",", ",").split(",") if api.strip() else [] @@ -147,20 +147,20 @@ class NotificationService: return results async def send_to_ntfy( - self, - api: str, - title: str = "message", - content: str = "test", - tags: str = "tada", - priority: int = 3, - action_url: str = "", - attach: str = "", - filename: str = "", - click: str = "", - icon: str = "", - delay: str = "", - email: str = "", - call: str = "", + self, + api: str, + title: str = "message", + content: str = "test", + tags: str = "tada", + priority: int = 3, + action_url: str = "", + attach: str = "", + filename: str = "", + click: str = "", + icon: str = "", + delay: str = "", + email: str = "", + call: str = "", ) -> dict[str, Any]: results = {"success": [], "error": []} api_list = api.replace(",", ",").split(",") if api.strip() else [] @@ -194,38 +194,32 @@ class NotificationService: return results async def send_to_serverchan( - self, - sendkey: str, - title: str = "message", - content: str = "test", - short: str = "", - channel: int = 9, - tags: str = "partying_face" + self, + sendkey: str, + title: str = "message", + content: str = "test", + short: str = "", + channel: int = 9, + tags: str = "partying_face", ) -> dict[str, Any]: results = {"success": [], "error": []} sendkey_list = sendkey.replace(",", ",").split(",") if sendkey.strip() else [] for key in sendkey_list: - if key.startswith('sctp'): - match = re.match(r'sctp(\d+)t', key) + if key.startswith("sctp"): + match = re.match(r"sctp(\d+)t", key) if match: num = match.group(1) - url = f'https://{num}.push.ft07.com/send/{key}.send' + url = f"https://{num}.push.ft07.com/send/{key}.send" else: logger.error(f"Invalid sendkey format for sctp: {key}") results["error"].append(key) continue else: - url = f'https://sctapi.ftqq.com/{key}.send' + url = f"https://sctapi.ftqq.com/{key}.send" - json_data = { - "title": title, - "desp": content, - "short": short, - "channel": channel, - "tags": tags - } + json_data = {"title": title, "desp": content, "short": short, "channel": channel, "tags": tags} resp = await self._async_post(url, json_data) if resp.get("code") == 0: results["success"].append(key) @@ -235,18 +229,13 @@ class NotificationService: return results - async def send_to_feishu( - self, url: str, content: str - ) -> dict[str, list[str]]: + async def send_to_feishu(self, url: str, content: str) -> dict[str, list[str]]: results = {"success": [], "error": []} api_list = [u.strip() for u in url.replace(",", ",").split(",") if u.strip()] for api in api_list: - json_data = { - "msg_type": "text", - "content": {"text": content} - } + json_data = {"msg_type": "text", "content": {"text": content}} resp = await self._async_post(api, json_data) - if resp.get("msg") == 'success': + if resp.get("msg") == "success": results["success"].append(api) else: results["error"].append(api) diff --git a/app/models/recording/recording_model.py b/app/models/recording/recording_model.py index 8a7e8e9..1226e4b 100644 --- a/app/models/recording/recording_model.py +++ b/app/models/recording/recording_model.py @@ -18,7 +18,7 @@ class Recording: recording_dir, enabled_message_push, only_notify_no_record, - flv_use_direct_download + flv_use_direct_download, ): """ Initialize a recording object. @@ -103,7 +103,7 @@ class Recording: "platform": self.platform, "platform_key": self.platform_key, "only_notify_no_record": self.only_notify_no_record, - "flv_use_direct_download": self.flv_use_direct_download + "flv_use_direct_download": self.flv_use_direct_download, } @classmethod @@ -124,7 +124,7 @@ class Recording: data.get("recording_dir"), data.get("enabled_message_push"), data.get("only_notify_no_record"), - data.get("flv_use_direct_download") + data.get("flv_use_direct_download"), ) recording.title = data.get("title", recording.title) recording.display_title = data.get("display_title", recording.title) diff --git a/app/scripts/ffmpeg_install.py b/app/scripts/ffmpeg_install.py index cc2d0f9..6c829e9 100644 --- a/app/scripts/ffmpeg_install.py +++ b/app/scripts/ffmpeg_install.py @@ -46,7 +46,6 @@ def _sync_unzip(zip_path: str | Path, extract_to: str | Path) -> None: async def get_lanzou_download_link(url: str, password: str | None = None, headers: dict | None = None) -> str | None: try: - async with httpx.AsyncClient(timeout=60.0) as client: response = await client.get(url, headers=headers) html_str = response.text @@ -60,9 +59,7 @@ async def get_lanzou_download_link(url: str, password: str | None = None, header } response = await client.post( - "https://wweb.lanzoum.com/ajaxm.php?file=219989236", - headers=headers, - data=data + "https://wweb.lanzoum.com/ajaxm.php?file=219989236", headers=headers, data=data ) json_data = response.json() download_url = json_data["dom"] + "/file/" + json_data["url"] @@ -78,13 +75,13 @@ async def install_ffmpeg_windows(update_progress): logger.debug("Installing the latest version of ffmpeg for Windows...") await update_progress(0.1, "Get FFmpeg installation resources") headers = { - 'content-type': 'application/x-www-form-urlencoded', - 'accept-language': 'zh-CN,zh;q=0.9', - 'origin': 'https://wweb.lanzoum.com', - 'referer': 'https://wweb.lanzoum.com/iHAc22ly3r3g', - 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)' - ' Chrome/121.0.0.0 Safari/537.36 Edg/121.0.0.0', - 'x-requested-with': 'XMLHttpRequest', + "content-type": "application/x-www-form-urlencoded", + "accept-language": "zh-CN,zh;q=0.9", + "origin": "https://wweb.lanzoum.com", + "referer": "https://wweb.lanzoum.com/iHAc22ly3r3g", + "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)" + " Chrome/121.0.0.0 Safari/537.36 Edg/121.0.0.0", + "x-requested-with": "XMLHttpRequest", } ffmpeg_url = await get_lanzou_download_link("https://wweb.lanzoum.com/iHAc22ly3r3g", "eots", headers) if ffmpeg_url: @@ -97,9 +94,10 @@ async def install_ffmpeg_windows(update_progress): else: await update_progress(0.2, "Start downloading FFmpeg installation package") logger.debug(f"FFmpeg Download ({version}): {ffmpeg_url}") - async with (httpx.AsyncClient(follow_redirects=True) as client, - client.stream("GET", ffmpeg_url, headers=headers) as resp): - + async with ( + httpx.AsyncClient(follow_redirects=True) as client, + client.stream("GET", ffmpeg_url, headers=headers) as resp, + ): total_size = int(resp.headers.get("Content-Length", 0)) if resp.status_code != 200 and total_size != 0: logger.error("FFmpeg package resources cannot be accessed") @@ -142,8 +140,9 @@ async def install_ffmpeg_mac(update_progress): await update_progress(0.3, "Please be patient and wait...") timeout = 300 try: - result = subprocess.run(["brew", "install", "ffmpeg"], capture_output=True, - startupinfo=startupinfo, timeout=timeout) + result = subprocess.run( + ["brew", "install", "ffmpeg"], capture_output=True, startupinfo=startupinfo, timeout=timeout + ) if result.returncode == 0: logger.success("FFmpeg installation was successful. Restart for changes to take effect.") return True @@ -168,14 +167,16 @@ async def install_ffmpeg_linux(update_progress): logger.debug("Trying to install the stable version of ffmpeg") await update_progress(0.1, "Get FFmpeg installation resources") try: - result = subprocess.run(["yum", "-y", "update"], capture_output=True, - startupinfo=startupinfo, timeout=timeout) + result = subprocess.run( + ["yum", "-y", "update"], capture_output=True, startupinfo=startupinfo, timeout=timeout + ) if result.returncode != 0: logger.error("Failed to update package lists using yum.") return False - result = subprocess.run(["yum", "install", "-y", "ffmpeg"], capture_output=True, - startupinfo=startupinfo, timeout=timeout) + result = subprocess.run( + ["yum", "install", "-y", "ffmpeg"], capture_output=True, startupinfo=startupinfo, timeout=timeout + ) if result.returncode == 0: logger.success("ffmpeg installation was successful using yum. Restart for changes to take effect.") return True @@ -193,14 +194,16 @@ async def install_ffmpeg_linux(update_progress): try: logger.debug("Trying to install the stable version of ffmpeg for Linux using apt...") try: - result = subprocess.run(["apt", "update"], capture_output=True, - startupinfo=startupinfo, timeout=timeout) + result = subprocess.run( + ["apt", "update"], capture_output=True, startupinfo=startupinfo, timeout=timeout + ) if result.returncode != 0: logger.error("Failed to update package lists using apt") return False - result = subprocess.run(["apt", "install", "-y", "ffmpeg"], capture_output=True, - startupinfo=startupinfo, timeout=timeout) + result = subprocess.run( + ["apt", "install", "-y", "ffmpeg"], capture_output=True, startupinfo=startupinfo, timeout=timeout + ) if result.returncode == 0: logger.success("ffmpeg installation was successful using apt. Restart for changes to take effect.") return True diff --git a/app/scripts/node_install.py b/app/scripts/node_install.py index ad17e2c..ce86622 100644 --- a/app/scripts/node_install.py +++ b/app/scripts/node_install.py @@ -120,7 +120,7 @@ async def install_nodejs_centos(update_progress): "curl -fsSL https://mirrors.tuna.tsinghua.edu.cn/nodesource/rpm/setup_lts.x | bash -", shell=True, capture_output=True, - startupinfo=startupinfo + startupinfo=startupinfo, ) if result.returncode != 0: logger.error("Failed to run NodeSource installation script") diff --git a/app/ui/base_page.py b/app/ui/base_page.py index 60ec08e..22de001 100644 --- a/app/ui/base_page.py +++ b/app/ui/base_page.py @@ -13,6 +13,5 @@ class PageBase: self._ = {} async def load(self): - """Load page content into the content area. - """ + """Load page content into the content area.""" raise NotImplementedError("Subclasses must implement this method") diff --git a/app/ui/components/business/recording_card.py b/app/ui/components/business/recording_card.py index 708a2f3..61da290 100644 --- a/app/ui/components/business/recording_card.py +++ b/app/ui/components/business/recording_card.py @@ -40,9 +40,9 @@ class RecordingCardManager: if not self.cards_obj.get(rec_id): check_live_on_browser_refresh = self.app.settings.user_config.get("check_live_on_browser_refresh", True) if self.app.recording_enabled and not subscribe_add_cards: - if check_live_on_browser_refresh or recording.streamer_name == self._['live_room']: + if check_live_on_browser_refresh or recording.streamer_name == self._["live_room"]: self.app.page.run_task(self.app.record_manager.check_if_live, recording) - + card_data = self._create_card_components(recording) self.cards_obj[rec_id] = card_data self.start_update_task(recording) @@ -55,30 +55,35 @@ class RecordingCardManager: record_button = ft.IconButton( icon=self.get_icon_for_recording_state(recording), + icon_color=ft.Colors.PRIMARY, tooltip=self.get_tip_for_recording_state(recording), on_click=lambda e, rec=recording: self.app.page.run_task(self.recording_button_on_click, e, rec), ) edit_button = ft.IconButton( icon=ft.Icons.EDIT, + icon_color=ft.Colors.PRIMARY, tooltip=self._["edit_record_config"], on_click=lambda e, rec=recording: self.app.page.run_task(self.edit_recording_button_click, e, rec), ) preview_button = ft.IconButton( icon=ft.Icons.VIDEO_LIBRARY, + icon_color=ft.Colors.PRIMARY, tooltip=self._["preview_video"], on_click=lambda e, rec=recording: self.app.page.run_task(self.preview_video_button_on_click, e, rec), ) monitor_button = ft.IconButton( icon=self.get_icon_for_monitor_state(recording), + icon_color=ft.Colors.PRIMARY, tooltip=self.get_tip_for_monitor_state(recording), on_click=lambda e, rec=recording: self.app.page.run_task(self.monitor_button_on_click, e, rec), ) delete_button = ft.IconButton( icon=ft.Icons.DELETE, + icon_color=ft.Colors.PRIMARY, tooltip=self._["delete_monitor"], on_click=lambda e, rec=recording: self.app.page.run_task(self.recording_delete_button_click, e, rec), ) @@ -97,11 +102,13 @@ class RecordingCardManager: open_folder_button = ft.IconButton( icon=ft.Icons.FOLDER, + icon_color=ft.Colors.PRIMARY, tooltip=self._["open_folder"], on_click=lambda e, rec=recording: self.app.page.run_task(self.recording_dir_button_on_click, e, rec), ) recording_info_button = ft.IconButton( icon=ft.Icons.INFO, + icon_color=ft.Colors.PRIMARY, tooltip=self._["recording_info"], on_click=lambda e, rec=recording: self.app.page.run_task(self.recording_info_button_on_click, e, rec), ) @@ -130,15 +137,15 @@ class RecordingCardManager: preview_button, edit_button, delete_button, - monitor_button + monitor_button, ], spacing=3, alignment=ft.MainAxisAlignment.START, - scroll=ft.ScrollMode.HIDDEN + scroll=ft.ScrollMode.HIDDEN, ), ], spacing=3, - tight=True + tight=True, ), padding=8, on_click=lambda e, rec=recording: self.app.page.run_task(self.recording_card_on_click, e, rec), @@ -178,18 +185,13 @@ class RecordingCardManager: return None return ft.Container( - content=ft.Text( - config["text"], - color=config["text_color"], - size=12, - weight=ft.FontWeight.BOLD - ), + content=ft.Text(config["text"], color=config["text_color"], size=12, weight=ft.FontWeight.BOLD), bgcolor=config["bgcolor"], border_radius=5, padding=5, width=60, height=26, - alignment=ft.alignment.center, + alignment=ft.alignment.Alignment.CENTER, ) async def update_card(self, recording): @@ -240,11 +242,11 @@ class RecordingCardManager: recording_card["card"].content.border = ft.border.all(2, self.get_card_border_color(recording)) try: self.app.page.update() - except (ft.core.page.PageDisconnectedException, AssertionError) as e: + except (ft.FletPageDisconnectedException, AssertionError) as e: logger.debug(f"Update card failed: {e}") return - except (ft.core.page.PageDisconnectedException, AssertionError) as e: + except (ft.FletPageDisconnectedException, AssertionError) as e: logger.debug(f"Update card failed: {e}") return except Exception as e: @@ -287,9 +289,9 @@ class RecordingCardManager: self.app.dialog_area.content = dialog try: self.app.page.update() - except (ft.core.page.PageDisconnectedException, AssertionError) as e: + except (ft.FletPageDisconnectedException, AssertionError) as e: logger.debug(f"Update recording info dialog failed: {e}") - except (ft.core.page.PageDisconnectedException, AssertionError) as e: + except (ft.FletPageDisconnectedException, AssertionError) as e: logger.debug(f"Show recording info dialog failed: {e}") except Exception as e: logger.debug(f"Show recording info dialog failed: {e}") @@ -304,7 +306,8 @@ class RecordingCardManager: recording.display_title = f"[{self._['monitor_stopped']}] " + recording.title recording.scheduled_time_range = await self.app.record_manager.get_scheduled_time_range( - recording.scheduled_start_time, recording.monitor_hours) + recording.scheduled_start_time, recording.monitor_hours + ) await self.update_card(recording) self.app.page.pubsub.send_others_on_topic("update", recording_dict) @@ -349,9 +352,7 @@ class RecordingCardManager: keep_ids = existing_ids - remove_ids cards_to_remove = [ - card_data["card"] - for rec_id, card_data in self.cards_obj.items() - if rec_id not in keep_ids + card_data["card"] for rec_id, card_data in self.cards_obj.items() if rec_id not in keep_ids ] recordings_page.recording_card_area.content.controls = [ @@ -360,17 +361,14 @@ class RecordingCardManager: if control not in cards_to_remove ] - self.cards_obj = { - k: v for k, v in self.cards_obj.items() - if k in keep_ids - } + self.cards_obj = {k: v for k, v in self.cards_obj.items() if k in keep_ids} try: recordings_page.recording_card_area.update() - except (ft.core.page.PageDisconnectedException, AssertionError) as e: + except (ft.FletPageDisconnectedException, AssertionError) as e: logger.debug(f"Update recording card area failed: {e}") - except (ft.core.page.PageDisconnectedException, AssertionError) as e: + except (ft.FletPageDisconnectedException, AssertionError) as e: logger.debug(f"Remove recording card failed: {e}") except Exception as e: logger.debug(f"Remove recording card failed: {e}") @@ -408,7 +406,7 @@ class RecordingCardManager: duration_label = self.cards_obj[recording.rec_id]["duration_label"] duration_label.value = self.app.record_manager.get_duration(recording) duration_label.update() - except (ft.core.page.PageDisconnectedException, AssertionError) as e: + except (ft.FletPageDisconnectedException, AssertionError) as e: logger.debug(f"Update duration failed: {e}") break except Exception as e: @@ -426,9 +424,9 @@ class RecordingCardManager: self.cards_obj[recording.rec_id]["card"].content.bgcolor = await self.update_record_hover(recording) try: self.cards_obj[recording.rec_id]["card"].update() - except (ft.core.page.PageDisconnectedException, AssertionError) as e: + except (ft.FletPageDisconnectedException, AssertionError) as e: logger.debug(f"Update card click state failed: {e}") - except (ft.core.page.PageDisconnectedException, AssertionError) as e: + except (ft.FletPageDisconnectedException, AssertionError) as e: logger.debug(f"Handle card click event failed: {e}") except Exception as e: logger.debug(f"Handle card click event failed: {e}") @@ -437,7 +435,7 @@ class RecordingCardManager: if recording.recording_dir: if os.path.exists(recording.recording_dir): if not utils.open_folder(recording.recording_dir): - await self.app.snack_bar.show_snack_bar(self._['no_video_file']) + await self.app.snack_bar.show_snack_bar(self._["no_video_file"]) else: await self.app.snack_bar.show_snack_bar(self._["no_recording_folder"]) @@ -456,6 +454,7 @@ class RecordingCardManager: async def recording_delete_button_click(self, _, recording: Recording): try: + async def confirm_dlg(_): self.app.page.run_task(self.on_delete_recording, recording) await close_dialog(None) @@ -464,15 +463,15 @@ class RecordingCardManager: try: delete_alert_dialog.open = False delete_alert_dialog.update() - except (ft.core.page.PageDisconnectedException, AssertionError) as err: + except (ft.FletPageDisconnectedException, AssertionError) as err: logger.debug(f"Close delete dialog failed: {err}") delete_alert_dialog = ft.AlertDialog( title=ft.Text(self._["confirm"]), content=ft.Text(self._["delete_confirm_tip"]), actions=[ - ft.TextButton(text=self._["cancel"], on_click=close_dialog), - ft.TextButton(text=self._["sure"], on_click=confirm_dlg), + ft.TextButton(content=self._["cancel"], on_click=close_dialog), + ft.TextButton(content=self._["sure"], on_click=confirm_dlg), ], actions_alignment=ft.MainAxisAlignment.END, modal=False, @@ -481,9 +480,9 @@ class RecordingCardManager: self.app.dialog_area.content = delete_alert_dialog try: self.app.page.update() - except (ft.core.page.PageDisconnectedException, AssertionError) as e: + except (ft.FletPageDisconnectedException, AssertionError) as e: logger.debug(f"Update delete dialog failed: {e}") - except (ft.core.page.PageDisconnectedException, AssertionError) as e: + except (ft.FletPageDisconnectedException, AssertionError) as e: logger.debug(f"Show delete dialog failed: {e}") except Exception as e: logger.debug(f"Show delete dialog failed: {e}") diff --git a/app/ui/components/business/recording_dialog.py b/app/ui/components/business/recording_dialog.py index 8916023..b926844 100644 --- a/app/ui/components/business/recording_dialog.py +++ b/app/ui/components/business/recording_dialog.py @@ -38,15 +38,17 @@ class RecordingDialog: async def on_url_change(_): """Enable or disable the submit button based on whether the URL field is filled.""" - is_active = utils.is_valid_url(url_field.value.strip()) or utils.contains_url(batch_input.value.strip()) + url_value = url_field.value.strip() if url_field.value else "" + batch_value = batch_input.value.strip() if batch_input.value else "" + is_active = utils.is_valid_url(url_value) or utils.contains_url(batch_value) dialog.actions[1].disabled = not is_active self.page.update() async def update_format_options(e): if e.control.value == "video": - record_format_field.options = [ft.dropdown.Option(i) for i in VideoFormat.get_formats()] + record_format_field.options = [ft.dropdown.DropdownOption(i) for i in VideoFormat.get_formats()] else: - record_format_field.options = [ft.dropdown.Option(i) for i in AudioFormat.get_formats()] + record_format_field.options = [ft.dropdown.DropdownOption(i) for i in AudioFormat.get_formats()] record_format_field.value = record_format_field.options[0].key record_format_field.update() @@ -55,6 +57,7 @@ class RecordingDialog: hint_text=self._["example"] + ":https://www.example.com/xxxxxx", border_radius=5, filled=False, + expand=True, value=initial_values.get("url"), on_change=on_url_change, ) @@ -64,17 +67,18 @@ class RecordingDialog: hint_text=self._["default_input"], border_radius=5, filled=False, + expand=True, value=initial_values.get("streamer_name", ""), ) media_type_dropdown = ft.Dropdown( label=self._["select_media_type"], options=[ - ft.dropdown.Option("video", text=self._["video"]), - ft.dropdown.Option("audio", text=self._["audio"]) + ft.dropdown.DropdownOption("video", text=self._["video"]), + ft.dropdown.DropdownOption("audio", text=self._["audio"]), ], width=245, value=default_record_type, - on_change=update_format_options + on_select=update_format_options, ) if default_record_type == "video": @@ -83,17 +87,17 @@ class RecordingDialog: record_formats = AudioFormat.get_formats() record_format_field = ft.Dropdown( label=self._["select_record_format"], - options=[ft.dropdown.Option(i) for i in record_formats], + options=[ft.dropdown.DropdownOption(i) for i in record_formats], border_radius=5, filled=False, value=default_record_format, width=245, - menu_height=200 + menu_height=200, ) quality_dropdown = ft.Dropdown( label=self._["select_resolution"], - options=[ft.dropdown.Option(i, text=self._[i]) for i in VideoQuality.get_qualities()], + options=[ft.dropdown.DropdownOption(i, text=self._[i]) for i in VideoQuality.get_qualities()], border_radius=5, filled=False, value=default_record_quality, @@ -103,14 +107,14 @@ class RecordingDialog: flv_use_direct_download_dropdown = ft.Dropdown( label=self._["flv_use_direct_download"], options=[ - ft.dropdown.Option("true", self._["yes"]), - ft.dropdown.Option("false", self._["no"]), + ft.dropdown.DropdownOption("true", self._["yes"]), + ft.dropdown.DropdownOption("false", self._["no"]), ], border_radius=5, filled=False, value="true" if flv_use_direct_download else "false", width=245, - tooltip=self._["flv_use_direct_download_tip"] + tooltip=self._["flv_use_direct_download_tip"], ) if self.app.is_mobile: @@ -129,6 +133,7 @@ class RecordingDialog: hint_text=self._["default_input"], border_radius=5, filled=False, + expand=True, value=initial_values.get("recording_dir"), ) @@ -140,13 +145,13 @@ class RecordingDialog: segment_setting_dropdown = ft.Dropdown( label=self._["is_segment_enabled"], options=[ - ft.dropdown.Option(self._["yes"]), - ft.dropdown.Option(self._["no"]), + ft.dropdown.DropdownOption(self._["yes"]), + ft.dropdown.DropdownOption(self._["no"]), ], border_radius=5, filled=False, value=self._["yes"] if segment_record else self._["no"], - on_change=on_segment_setting_change, + on_select=on_segment_setting_change, width=500, ) @@ -155,21 +160,22 @@ class RecordingDialog: hint_text=self._["input_segment_time"], border_radius=5, filled=False, + expand=True, value=segment_time, visible=segment_record, ) scheduled_recording = initial_values.get("scheduled_recording", False) - scheduled_start_time = initial_values.get("scheduled_start_time", '') - monitor_hours = initial_values.get("monitor_hours", '5') - message_push_enabled = initial_values.get('enabled_message_push', True) + scheduled_start_time = initial_values.get("scheduled_start_time", "") + monitor_hours = initial_values.get("monitor_hours", "5") + message_push_enabled = initial_values.get("enabled_message_push", True) time_slots = 2 time_inputs = [] hour_inputs = [] time_buttons = [] time_picker_handlers = [] - + time_values = scheduled_start_time.split(",") time_values = (time_values + [""] * time_slots)[:time_slots] @@ -183,17 +189,18 @@ class RecordingDialog: time_inputs[index].update() time_picker = ft.TimePicker( - confirm_text=self._['confirm'], - cancel_text=self._['cancel'], - error_invalid_text=self._['time_out_of_range'], - help_text=self._['pick_time_slot'], - hour_label_text=self._['hour_label_text'], - minute_label_text=self._['minute_label_text'], - on_change=handle_change + confirm_text=self._["confirm"], + cancel_text=self._["cancel"], + error_invalid_text=self._["time_out_of_range"], + help_text=self._["pick_time_slot"], + hour_label_text=self._["hour_label_text"], + minute_label_text=self._["minute_label_text"], + on_change=handle_change, ) self.page.open(time_picker) + return pick_time - + for i in range(time_slots): time_input = ft.TextField( label=self._["scheduled_start_time"], @@ -203,7 +210,7 @@ class RecordingDialog: value=time_values[i], ) time_inputs.append(time_input) - + hour_input = ft.TextField( label=self._["monitor_hours"], hint_text=self._["example"] + ":5", @@ -214,18 +221,15 @@ class RecordingDialog: visible=scheduled_recording, ) hour_inputs.append(hour_input) - + handler = create_time_picker_handler(i) time_picker_handlers.append(handler) - - button = ft.ElevatedButton( - self._['pick_time'], - icon=ft.Icons.TIME_TO_LEAVE, - on_click=handler, - tooltip=self._['pick_time_tip'] + + button = ft.Button( + self._["pick_time"], icon=ft.Icons.TIME_TO_LEAVE, on_click=handler, tooltip=self._["pick_time_tip"] ) time_buttons.append(button) - + async def on_scheduled_setting_change(e): selected_value = e.control.value for i in range(time_slots): @@ -236,13 +240,13 @@ class RecordingDialog: scheduled_setting_dropdown = ft.Dropdown( label=self._["scheduled_recording"], options=[ - ft.dropdown.Option("true", self._["yes"]), - ft.dropdown.Option("false", self._["no"]), + ft.dropdown.DropdownOption("true", self._["yes"]), + ft.dropdown.DropdownOption("false", self._["no"]), ], border_radius=5, filled=False, value="true" if scheduled_recording else "false", - on_change=on_scheduled_setting_change, + on_select=on_scheduled_setting_change, width=500, ) @@ -262,8 +266,8 @@ class RecordingDialog: message_push_dropdown = ft.Dropdown( label=self._["enable_message_push"], options=[ - ft.dropdown.Option("true", self._["yes"]), - ft.dropdown.Option("false", self._["no"]), + ft.dropdown.DropdownOption("true", self._["yes"]), + ft.dropdown.DropdownOption("false", self._["no"]), ], border_radius=5, filled=False, @@ -274,8 +278,8 @@ class RecordingDialog: no_record_dropdown = ft.Dropdown( label=self._["only_notify_no_record"], options=[ - ft.dropdown.Option("true", self._["yes"]), - ft.dropdown.Option("false", self._["no"]), + ft.dropdown.DropdownOption("true", self._["yes"]), + ft.dropdown.DropdownOption("false", self._["no"]), ], border_radius=5, filled=False, @@ -311,36 +315,46 @@ class RecordingDialog: tabs = ft.Tabs( selected_index=0, animation_duration=300, - height=500, - tabs=[ - ft.Tab( - text=self._["single_input"], - content=ft.Container( - content=ft.Column( - [ - ft.Container(margin=ft.margin.only(top=10)), - url_field, - streamer_name_field, - format_row, - quality_row, - recording_dir_field, - segment_setting_dropdown, - segment_input, - scheduled_setting_dropdown, - *time_rows, - message_push_dropdown, - no_record_dropdown - ], - tight=True, - spacing=10, - scroll=ft.ScrollMode.AUTO, - ) + content=ft.Column( + [ + ft.TabBar( + tabs=[ + ft.Tab(label=self._["single_input"]), + ft.Tab(label=self._["batch_input"]), + ] ), - ), - ft.Tab( - text=self._["batch_input"], content=ft.Container(content=batch_input, margin=ft.margin.only(top=15)) - ), - ], + ft.TabBarView( + controls=[ + ft.Container( + content=ft.Column( + [ + ft.Container(margin=ft.margin.only(top=10)), + url_field, + streamer_name_field, + format_row, + quality_row, + recording_dir_field, + segment_setting_dropdown, + segment_input, + scheduled_setting_dropdown, + *time_rows, + message_push_dropdown, + no_record_dropdown, + ], + tight=True, + spacing=10, + scroll=ft.ScrollMode.AUTO, + ) + ), + ft.Container(content=batch_input, margin=ft.margin.only(top=15)), + ], + expand=True, + ), + ], + height=500, + expand=True, + ), + length=2, ) async def not_supported(url): @@ -388,9 +402,9 @@ class RecordingDialog: "segment_time": segment_input.value, "monitor_status": initial_values.get("monitor_status", True), "display_title": display_title, - "scheduled_recording": scheduled_setting_dropdown.value == 'true', - "scheduled_start_time": ','.join([str(i.value) for i in time_inputs]), - "monitor_hours": ','.join([str(i.value) for i in hour_inputs]), + "scheduled_recording": scheduled_setting_dropdown.value == "true", + "scheduled_start_time": ",".join([str(i.value) for i in time_inputs]), + "monitor_hours": ",".join([str(i.value) for i in hour_inputs]), "recording_dir": recording_dir_field.value, "enabled_message_push": message_push_dropdown.value == "true", "only_notify_no_record": no_record_dropdown.value == "true", @@ -399,6 +413,7 @@ class RecordingDialog: ] if live_url in existing_recordings and not rec_id: + async def confirm_duplicate(): async def close_duplicate_dialog(_): self.url_duplicate_confirm_dialog.open = False @@ -487,7 +502,9 @@ class RecordingDialog: dialog.open = False self.page.update() - close_button = ft.IconButton(icon=ft.Icons.CLOSE, tooltip=self._["close"], on_click=close_dialog) + close_button = ft.IconButton( + icon=ft.Icons.CLOSE, icon_color=ft.Colors.PRIMARY, tooltip=self._["close"], on_click=close_dialog + ) title_text = self._["edit_record"] if self.recording else self._["add_record"] dialog = ft.AlertDialog( @@ -500,15 +517,15 @@ class RecordingDialog: close_button, ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN, - width=500 + width=500, ), content=tabs, actions=[ - ft.TextButton(text=self._["cancel"], on_click=close_dialog), - ft.TextButton(text=self._["sure"], on_click=on_confirm, disabled=self.recording is None), + ft.TextButton(content=self._["cancel"], on_click=close_dialog), + ft.TextButton(content=self._["sure"], on_click=on_confirm, disabled=self.recording is None), ], actions_alignment=ft.MainAxisAlignment.END, - shape=ft.RoundedRectangleBorder(radius=10) + shape=ft.RoundedRectangleBorder(radius=10), ) self.page.overlay.append(dialog) diff --git a/app/ui/components/business/video_player.py b/app/ui/components/business/video_player.py index e66a84b..9d5bd4f 100644 --- a/app/ui/components/business/video_player.py +++ b/app/ui/components/business/video_player.py @@ -21,10 +21,7 @@ class VideoPlayer: self._.update(language.get(key, {})) async def create_video_dialog( - self, title: str, - video_source: str, - is_file_path: bool = True, - room_url: str | None = None + self, title: str, video_source: str, is_file_path: bool = True, room_url: str | None = None ): """ Create video playback dialog @@ -48,22 +45,17 @@ class VideoPlayer: video_height = 450 video = ftv.Video( - width=video_width, - height=video_height, - playlist=[ftv.VideoMedia(video_source)], - autoplay=True + width=video_width, height=video_height, playlist=[ftv.VideoMedia(video_source)], autoplay=True ) async def copy_source(_): - self.app.page.set_clipboard(video_source) + await ft.Clipboard().set(video_source) await self.app.snack_bar.show_snack_bar(self._["copy_success"]) async def open_in_browser(_): self.app.page.launch_url(room_url) - actions = [ - ft.TextButton(self._["close"], on_click=close_dialog) - ] + actions = [ft.TextButton(self._["close"], on_click=close_dialog)] if room_url: actions.insert(0, ft.TextButton(self._["open_live_room_page"], on_click=open_in_browser)) @@ -83,7 +75,7 @@ class VideoPlayer: video_container = ft.Container( content=video, - alignment=ft.alignment.center, + alignment=ft.alignment.Alignment.CENTER, width=video_width, height=video_height, ) @@ -92,10 +84,7 @@ class VideoPlayer: modal=True, title=ft.Text(title, overflow=ft.TextOverflow.ELLIPSIS, max_lines=1, size=14), content=ft.Column( - [ - video_container, - actions_row - ], + [video_container, actions_row], spacing=5, alignment=ft.MainAxisAlignment.CENTER, horizontal_alignment=ft.CrossAxisAlignment.CENTER, @@ -111,7 +100,7 @@ class VideoPlayer: title=ft.Text(title), content=video, actions=actions, - actions_alignment=ft.MainAxisAlignment.END + actions_alignment=ft.MainAxisAlignment.END, ) dialog.open = True self.app.dialog_area.content = dialog @@ -128,14 +117,15 @@ class VideoPlayer: if not utils.is_valid_video_file(source): logger.warning(f"unsupported file type: {Path(source).suffix.lower()}") await self.app.snack_bar.show_snack_bar( - self._["unsupported_file_type"] + ":" + os.path.basename(source)) + self._["unsupported_file_type"] + ":" + os.path.basename(source) + ) return title = os.path.basename(source) else: parsed = urlparse(source) params = parse_qs(parsed.query) - filename = params.get('filename', [''])[0] - sub_folder = params.get('subfolder', [''])[0] + filename = params.get("filename", [""])[0] + sub_folder = params.get("subfolder", [""])[0] if filename: title = self._["previewing"] + ": " + (f"{sub_folder}/{filename}" if sub_folder else filename) if Path(filename).suffix.lower() != ".mp4": @@ -143,4 +133,4 @@ class VideoPlayer: return else: title = self._["view_stream_source_now"] - await self.create_video_dialog(title, source, is_file_path, room_url) \ No newline at end of file + await self.create_video_dialog(title, source, is_file_path, room_url) diff --git a/app/ui/components/common/save_progress_overlay.py b/app/ui/components/common/save_progress_overlay.py index e9a6681..b6bff84 100644 --- a/app/ui/components/common/save_progress_overlay.py +++ b/app/ui/components/common/save_progress_overlay.py @@ -7,7 +7,7 @@ class SaveProgressOverlay: self._ = {} self.app.language_manager.add_observer(self) self.load() - + self.message_text = None self.cancel_button = None self.warning_text = None @@ -16,131 +16,113 @@ class SaveProgressOverlay: self.content_container = None self.simple_container = None self.overlay = ft.Stack([], visible=False) - + self.is_cancellable = False self.is_simple_mode = False self._initialized = False - + self._overlay_container = ft.Container() + def _initialize_components(self): if self._initialized: return - + self.message_text = ft.Text( - self._["saving_recording"], - size=18, + self._["saving_recording"], + size=18, weight=ft.FontWeight.W_500, - color=ft.colors.WHITE, - text_align=ft.TextAlign.CENTER + color=ft.Colors.WHITE, + text_align=ft.TextAlign.CENTER, ) - - self.cancel_button = ft.ElevatedButton( - text=f"😾 {self._['force_close']}", + + self.cancel_button = ft.Button( + content=f"😾 {self._['force_close']}", on_click=self._on_force_close, style=ft.ButtonStyle( - color=ft.colors.WHITE, + color=ft.Colors.WHITE, bgcolor="#FF5252", shape=ft.RoundedRectangleBorder(radius=8), elevation=0, padding=ft.padding.symmetric(horizontal=20, vertical=10), ), tooltip=self._["force_close_tooltip"], - visible=False + visible=False, ) - + self.warning_text = ft.Text( self._["force_close_warning"], size=12, - color=ft.colors.with_opacity(0.7, ft.colors.WHITE), + color=ft.Colors.with_opacity(0.7, ft.Colors.WHITE), text_align=ft.TextAlign.CENTER, - visible=False + visible=False, ) - - self.progress_ring = ft.ProgressRing( - width=60, - height=60, - stroke_width=4, - color="#2196F3", - value=0.7 - ) - - self.simple_progress_ring = ft.ProgressRing( - width=50, - height=50, - stroke_width=3, - color="#2196F3", - value=0.8 - ) - + + self.progress_ring = ft.ProgressRing(width=60, height=60, stroke_width=4, color="#2196F3", value=0.7) + + self.simple_progress_ring = ft.ProgressRing(width=50, height=50, stroke_width=3, color="#2196F3", value=0.8) + self.content_container = ft.Container( content=ft.Column( [ - ft.Container( - content=self.progress_ring, - margin=ft.margin.only(bottom=20) - ), + ft.Container(content=self.progress_ring, margin=ft.margin.only(bottom=20)), self.message_text, ft.Container(height=25), self.cancel_button, ft.Container(height=8), - self.warning_text + self.warning_text, ], alignment=ft.MainAxisAlignment.CENTER, horizontal_alignment=ft.CrossAxisAlignment.CENTER, - spacing=0 + spacing=0, ), width=400, height=280, padding=ft.padding.all(30), - alignment=ft.alignment.center, - bgcolor=ft.colors.with_opacity(0.95, "#212121"), + alignment=ft.alignment.Alignment.CENTER, + bgcolor=ft.Colors.with_opacity(0.95, "#212121"), border_radius=16, shadow=ft.BoxShadow( spread_radius=0, blur_radius=24, - color=ft.colors.with_opacity(0.5, ft.colors.BLACK), - offset=ft.Offset(0, 4) + color=ft.Colors.with_opacity(0.5, ft.Colors.BLACK), + offset=ft.Offset(0, 4), ), ) - + self.simple_container = ft.Container( content=ft.Column( [ - ft.Container( - content=self.simple_progress_ring, - margin=ft.margin.only(bottom=15) - ), + ft.Container(content=self.simple_progress_ring, margin=ft.margin.only(bottom=15)), self.message_text, ], alignment=ft.MainAxisAlignment.CENTER, horizontal_alignment=ft.CrossAxisAlignment.CENTER, - spacing=0 + spacing=0, ), width=300, height=180, padding=ft.padding.all(25), - alignment=ft.alignment.center, - bgcolor=ft.colors.with_opacity(0.95, "#212121"), + alignment=ft.alignment.Alignment.CENTER, + bgcolor=ft.Colors.with_opacity(0.95, "#212121"), border_radius=16, shadow=ft.BoxShadow( spread_radius=0, blur_radius=24, - color=ft.colors.with_opacity(0.5, ft.colors.BLACK), - offset=ft.Offset(0, 4) + color=ft.Colors.with_opacity(0.5, ft.Colors.BLACK), + offset=ft.Offset(0, 4), ), ) - - self.overlay.controls = [ - ft.Container( - content=self.content_container, - alignment=ft.alignment.center, - expand=True, - bgcolor=ft.colors.with_opacity(0.7, ft.colors.BLACK), - animate_opacity=300, - ) - ] - + + self._overlay_container = ft.Container( + content=self.content_container, + alignment=ft.alignment.Alignment.CENTER, + expand=True, + bgcolor=ft.Colors.with_opacity(0.7, ft.Colors.BLACK), + animate_opacity=300, + ) + self.overlay.controls = [self._overlay_container] + self._initialized = True - + def _on_force_close(self, e): self.message_text.value = self._["force_closing"] self.cancel_button.visible = False @@ -151,25 +133,25 @@ class SaveProgressOverlay: def show(self, message=None, cancellable=False): self._initialize_components() - + if message: self.message_text.value = message else: self.message_text.value = self._["saving_recording"] - + self.is_cancellable = cancellable - + if cancellable: self.is_simple_mode = False - self.overlay.controls[0].content = self.content_container + self._overlay_container.content = self.content_container self.cancel_button.visible = True self.warning_text.visible = True else: self.is_simple_mode = True - self.overlay.controls[0].content = self.simple_container + self._overlay_container.content = self.simple_container self.cancel_button.visible = False self.warning_text.visible = False - + self.overlay.visible = True self.overlay.update() @@ -177,11 +159,11 @@ class SaveProgressOverlay: if self._initialized: self.message_text.value = message self.message_text.update() - + def show_cancel_button(self): if not self._initialized: return - + if not self.cancel_button.visible and not self.is_simple_mode: self.cancel_button.visible = True self.warning_text.visible = True @@ -197,4 +179,4 @@ class SaveProgressOverlay: @property def visible(self): - return self.overlay.visible \ No newline at end of file + return self.overlay.visible diff --git a/app/ui/components/common/show_snackbar.py b/app/ui/components/common/show_snackbar.py index f846dcc..fac1237 100644 --- a/app/ui/components/common/show_snackbar.py +++ b/app/ui/components/common/show_snackbar.py @@ -5,14 +5,16 @@ class ShowSnackBar: def __init__(self, app): self.app = app - async def show_snack_bar(self, message, bgcolor=None, duration=1500, action=None, emoji=None, - show_close_icon=False): + async def show_snack_bar( + self, message, bgcolor=None, duration=1500, action=None, emoji=None, show_close_icon=False + ): """Helper method to show a snack bar with optional emoji.""" message_row = ft.Row( controls=[ - ft.Icon(name=ft.icons.NOTIFICATIONS, color=ft.colors.SURFACE_VARIANT, size=18) if not emoji else - ft.Text(emoji, size=20, no_wrap=False), + ft.Icon(icon=ft.Icons.NOTIFICATIONS, color=ft.Colors.ON_SURFACE_VARIANT, size=18) + if not emoji + else ft.Text(emoji, size=20, no_wrap=False), ft.Text(message, size=14, no_wrap=False), ], spacing=10, @@ -25,14 +27,12 @@ class ShowSnackBar: snack_bar = ft.SnackBar( content=ft.Container( content=ft.Row( - controls=[ - message_row - ], + controls=[message_row], alignment=ft.MainAxisAlignment.SPACE_BETWEEN, - vertical_alignment=ft.CrossAxisAlignment.CENTER + vertical_alignment=ft.CrossAxisAlignment.CENTER, ), padding=10, - border_radius=8 + border_radius=8, ), behavior=ft.SnackBarBehavior.FLOATING, action=action, @@ -50,4 +50,4 @@ class ShowSnackBar: snack_bar.open = True self.app.snack_bar_area.content = snack_bar - self.app.page.update() \ No newline at end of file + self.app.page.update() diff --git a/app/ui/components/dialogs/card_dialog.py b/app/ui/components/dialogs/card_dialog.py index a601962..aa08aaf 100644 --- a/app/ui/components/dialogs/card_dialog.py +++ b/app/ui/components/dialogs/card_dialog.py @@ -41,10 +41,10 @@ class CardDialog(ft.AlertDialog): save_path = recording.recording_dir or self._["no_recording_dir_tip"] status_info = RecordingStatus.MONITORING if recording.monitor_status else RecordingStatus.STOPPED_MONITORING recording_status_info = self._[recording.status_info or status_info] - should_push_message = MessagePusher.should_push_message(self.app.settings, recording, message_type='other') + should_push_message = MessagePusher.should_push_message(self.app.settings, recording, message_type="other") message_push = self._["enabled"] if should_push_message else self._["disabled"] if not should_push_message and recording.enabled_message_push: - message_push = self._["disabled"] + f' ({self._["not_config_tip"]})' + message_push = self._["disabled"] + f" ({self._['not_config_tip']})" only_notify_no_record = self._["enabled"] if recording.only_notify_no_record else self._["disabled"] dialog_content = ft.Column( diff --git a/app/ui/components/dialogs/help_dialog.py b/app/ui/components/dialogs/help_dialog.py index a0ae856..c7efa29 100644 --- a/app/ui/components/dialogs/help_dialog.py +++ b/app/ui/components/dialogs/help_dialog.py @@ -39,4 +39,4 @@ class HelpDialog(ft.AlertDialog): def close_panel(self, _): self.open = False - self.update() \ No newline at end of file + self.update() diff --git a/app/ui/components/dialogs/search_dialog.py b/app/ui/components/dialogs/search_dialog.py index 27eaa7e..b183c9c 100644 --- a/app/ui/components/dialogs/search_dialog.py +++ b/app/ui/components/dialogs/search_dialog.py @@ -31,28 +31,27 @@ class SearchDialog(ft.AlertDialog): expand=True, border_radius=5, border_color=ft.Colors.GREY_400, - focused_border_color=ft.Colors.BLUE, + focused_border_color=ft.Colors.PRIMARY, hint_style=ft.TextStyle(color=ft.Colors.GREY_500, size=14), ) self.actions = [ ft.TextButton( self._["cancel"], icon=ft.Icons.CLOSE, + icon_color=ft.Colors.PRIMARY, style=ft.ButtonStyle(shape=ft.RoundedRectangleBorder(radius=5)), on_click=self.close_dlg, ), ft.TextButton( self._["sure"], icon=ft.Icons.SEARCH, + icon_color=ft.Colors.PRIMARY, style=ft.ButtonStyle(shape=ft.RoundedRectangleBorder(radius=5)), on_click=self.submit_query, ), ] self.content = ft.Column( - [self.query, ft.Divider(height=1, thickness=1, color=ft.Colors.GREY_300)], - tight=True, - width=400, - height=300 + [self.query, ft.Divider(height=1, thickness=1, color=ft.Colors.GREY_300)], tight=True, width=400, height=300 ) self.actions_alignment = ft.MainAxisAlignment.END self.on_close = on_close @@ -62,7 +61,7 @@ class SearchDialog(ft.AlertDialog): language = self.recordings_page.app.language_manager.language for key in ("search_dialog", "recordings_page", "base"): self._.update(language.get(key, {})) - + # Ensure the language items related to filtering exist if "filter_all" not in self._: self._["filter_all"] = self._["all"] @@ -78,4 +77,4 @@ class SearchDialog(ft.AlertDialog): async def submit_query(self, e): query = self.query.value.strip() await self.recordings_page.filter_recordings(query) - await self.close_dlg(e) \ No newline at end of file + await self.close_dlg(e) diff --git a/app/ui/components/state/recording_card_state.py b/app/ui/components/state/recording_card_state.py index 3bdce2a..4f08e6b 100644 --- a/app/ui/components/state/recording_card_state.py +++ b/app/ui/components/state/recording_card_state.py @@ -5,9 +5,8 @@ from ....models.recording.recording_status_model import CardStateType, Recording class RecordingCardState: - ERROR_STATUSES = [RecordingStatus.RECORDING_ERROR, RecordingStatus.LIVE_STATUS_CHECK_ERROR] - + @staticmethod def get_card_state(recording: Recording) -> CardStateType: if recording.is_recording: @@ -18,14 +17,16 @@ class RecordingCardState: return CardStateType.CHECKING elif recording.is_live and recording.monitor_status and not recording.is_recording: return CardStateType.LIVE - elif (not recording.is_live and recording.monitor_status and - recording.status_info != RecordingStatus.NOT_IN_SCHEDULED_CHECK): + elif ( + not recording.is_live + and recording.monitor_status + and recording.status_info != RecordingStatus.NOT_IN_SCHEDULED_CHECK + ): return CardStateType.OFFLINE - elif (not recording.monitor_status or - recording.status_info == RecordingStatus.NOT_IN_SCHEDULED_CHECK): + elif not recording.monitor_status or recording.status_info == RecordingStatus.NOT_IN_SCHEDULED_CHECK: return CardStateType.STOPPED return CardStateType.UNKNOWN - + @staticmethod def get_border_color(recording: Recording) -> ft.Colors: state = RecordingCardState.get_card_state(recording) @@ -34,15 +35,15 @@ class RecordingCardState: CardStateType.ERROR: ft.Colors.RED, CardStateType.LIVE: ft.Colors.BLUE, CardStateType.OFFLINE: ft.Colors.AMBER, - CardStateType.STOPPED: ft.Colors.GREY, + CardStateType.STOPPED: ft.Colors.GREY_200, CardStateType.CHECKING: ft.Colors.PURPLE, } return color_map.get(state, ft.Colors.TRANSPARENT) - + @staticmethod def get_status_label_config(recording: Recording, language_dict: dict) -> dict: state = RecordingCardState.get_card_state(recording) - + configs = { CardStateType.RECORDING: { "text": language_dict.get("recording"), @@ -75,24 +76,24 @@ class RecordingCardState: "text_color": ft.Colors.WHITE, }, } - + return configs.get(state, {}) - + @staticmethod def get_display_title(recording: Recording, language_dict: dict) -> str: status_prefix = "" if not recording.monitor_status: status_prefix = f"[{language_dict.get('monitor_stopped')}] " return f"{status_prefix}{recording.title}" - + @staticmethod def get_title_weight(recording: Recording) -> ft.FontWeight: return ft.FontWeight.BOLD if recording.is_recording or recording.is_live or recording.is_checking else None - + @staticmethod - def get_recording_icon(recording: Recording) -> ft.Icons: + def get_recording_icon(recording: Recording) -> ft.IconData: return ft.Icons.STOP_CIRCLE if recording.is_recording else ft.Icons.PLAY_CIRCLE - + @staticmethod - def get_monitor_icon(recording: Recording) -> ft.Icons: + def get_monitor_icon(recording: Recording) -> ft.IconData: return ft.Icons.VISIBILITY if recording.monitor_status else ft.Icons.VISIBILITY_OFF diff --git a/app/ui/filters/__init__.py b/app/ui/filters/__init__.py index ee5f5a9..4d0abae 100644 --- a/app/ui/filters/__init__.py +++ b/app/ui/filters/__init__.py @@ -1,3 +1,3 @@ from .recording_filters import RecordingFilters -__all__ = ["RecordingFilters"] \ No newline at end of file +__all__ = ["RecordingFilters"] diff --git a/app/ui/filters/recording_filters.py b/app/ui/filters/recording_filters.py index 9f7f453..20ee644 100644 --- a/app/ui/filters/recording_filters.py +++ b/app/ui/filters/recording_filters.py @@ -3,30 +3,32 @@ from ..components.state.recording_card_state import RecordingCardState class RecordingFilters: - @staticmethod def _is_error_status(recording) -> bool: return recording.status_info in RecordingCardState.ERROR_STATUSES - + @staticmethod def _is_live_status(recording) -> bool: - return (recording.is_live - and recording.monitor_status - and not recording.is_recording - and recording.status_info not in RecordingCardState.ERROR_STATUSES - and recording.status_info != RecordingStatus.NOT_IN_SCHEDULED_CHECK) - + return ( + recording.is_live + and recording.monitor_status + and not recording.is_recording + and recording.status_info not in RecordingCardState.ERROR_STATUSES + and recording.status_info != RecordingStatus.NOT_IN_SCHEDULED_CHECK + ) + @staticmethod def _is_offline_status(recording) -> bool: - return (not recording.is_live - and recording.monitor_status - and recording.status_info not in RecordingCardState.ERROR_STATUSES - and recording.status_info != RecordingStatus.NOT_IN_SCHEDULED_CHECK) - + return ( + not recording.is_live + and recording.monitor_status + and recording.status_info not in RecordingCardState.ERROR_STATUSES + and recording.status_info != RecordingStatus.NOT_IN_SCHEDULED_CHECK + ) + @staticmethod def _is_stopped_status(recording) -> bool: - return (not recording.monitor_status - or recording.status_info == RecordingStatus.NOT_IN_SCHEDULED_CHECK) + return not recording.monitor_status or recording.status_info == RecordingStatus.NOT_IN_SCHEDULED_CHECK STATUS_FILTER_MAP = { "all": lambda rec: True, @@ -34,7 +36,7 @@ class RecordingFilters: "living": lambda rec: RecordingFilters._is_live_status(rec), "error": lambda rec: RecordingFilters._is_error_status(rec), "offline": lambda rec: RecordingFilters._is_offline_status(rec), - "stopped": lambda rec: RecordingFilters._is_stopped_status(rec) + "stopped": lambda rec: RecordingFilters._is_stopped_status(rec), } @classmethod diff --git a/app/ui/layout/__init__.py b/app/ui/layout/__init__.py index 0519ecb..e69de29 100644 --- a/app/ui/layout/__init__.py +++ b/app/ui/layout/__init__.py @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/app/ui/layout/responsive_layout.py b/app/ui/layout/responsive_layout.py index 4506688..b94a9d8 100644 --- a/app/ui/layout/responsive_layout.py +++ b/app/ui/layout/responsive_layout.py @@ -10,13 +10,13 @@ def is_mobile_device(page: ft.Page) -> bool: def setup_responsive_layout(page: ft.Page, app: App) -> None: _ = app.language_manager.language.get("sidebar", {}) - + if is_mobile_device(page): logger.info("mobile device detected, enable mobile layout") app.is_mobile = True app.left_navigation_menu.width = 0 app.left_navigation_menu.visible = False - + app.bottom_navigation = ft.NavigationBar( destinations=[ ft.NavigationBarDestination(icon=ft.Icons.HOME, label=_["home"]), @@ -26,20 +26,16 @@ def setup_responsive_layout(page: ft.Page, app: App) -> None: ft.NavigationBarDestination(icon=ft.Icons.INFO, label=_["about"]), ], on_change=lambda e: page.go( - f"/{['home', 'recordings', 'settings', 'storage', 'about'][e.control.selected_index]}"), + f"/{['home', 'recordings', 'settings', 'storage', 'about'][e.control.selected_index]}" + ), ) - + app.content_area.expand = True - + app.complete_page = ft.Column( expand=True, spacing=0, - controls=[ - app.content_area, - app.bottom_navigation, - app.dialog_area, - app.snack_bar_area - ] + controls=[app.content_area, app.bottom_navigation, app.dialog_area, app.snack_bar_area], ) else: logger.info("desktop device detected, enable desktop layout") @@ -52,5 +48,5 @@ def setup_responsive_layout(page: ft.Page, app: App) -> None: app.content_area, app.dialog_area, app.snack_bar_area, - ] - ) \ No newline at end of file + ], + ) diff --git a/app/ui/navigation/sidebar.py b/app/ui/navigation/sidebar.py index 9220594..5caec7f 100644 --- a/app/ui/navigation/sidebar.py +++ b/app/ui/navigation/sidebar.py @@ -21,7 +21,9 @@ class NavigationItem(ft.Container): self.destination = destination self.icon = destination.icon self.text = destination.label - self.content = ft.Row([ft.Icon(self.icon), ft.Text(self.text)]) + self.content = ft.Row( + [ft.Icon(destination.icon, color=ft.Colors.PRIMARY), ft.Text(destination.label, color=ft.Colors.PRIMARY)] + ) self.on_click = lambda e: item_clicked(e) @@ -33,7 +35,7 @@ class NavigationColumn(ft.Column): self.scroll = ft.ScrollMode.ALWAYS self.sidebar = sidebar self.selected_index = 0 - self.page = page + self.flet_page = page self.app = app self.controls = self.get_navigation_items() @@ -45,7 +47,7 @@ class NavigationColumn(ft.Column): def item_clicked(self, e): self.selected_index = e.control.destination.index self.update_selected_item() - self.page.go(f"/{e.control.destination.name}") + self.flet_page.go(f"/{e.control.destination.name}") def update_selected_item(self): for item in self.controls: @@ -63,7 +65,7 @@ class LeftNavigationMenu(ft.Column): super().__init__() self.app = app self.sidebar = app.sidebar - self.page = app.page + self.flet_page = app.page self.rail = None self.dark_light_text = None self.dark_light_icon = None @@ -76,21 +78,23 @@ class LeftNavigationMenu(ft.Column): def load(self): self._ = self.app.language_manager.language.get("sidebar") - self.rail = NavigationColumn(sidebar=self.sidebar, page=self.page, app=self.app) + self.rail = NavigationColumn(sidebar=self.sidebar, page=self.flet_page, app=self.app) - if self.page.theme_mode == ft.ThemeMode.DARK: - self.dark_light_text = ft.Text(self._["dark_theme"]) + if self.flet_page.theme_mode == ft.ThemeMode.DARK: + self.dark_light_text = ft.Text(self._["dark_theme"], color=ft.Colors.PRIMARY) self.dark_light_icon = ft.IconButton( icon=ft.Icons.BRIGHTNESS_HIGH_OUTLINED, tooltip=self._["toggle_day_theme"], on_click=self.theme_changed, + icon_color=ft.Colors.PRIMARY, ) else: - self.dark_light_text = ft.Text(self._["light_theme"]) + self.dark_light_text = ft.Text(self._["light_theme"], color=ft.Colors.PRIMARY) self.dark_light_icon = ft.IconButton( icon=ft.Icons.BRIGHTNESS_2_OUTLINED, tooltip=self._["toggle_night_theme"], on_click=self.theme_changed, + icon_color=ft.Colors.PRIMARY, ) colors_list = [ @@ -123,10 +127,11 @@ class LeftNavigationMenu(ft.Column): controls=[ ft.PopupMenuButton( icon=ft.Icons.COLOR_LENS_OUTLINED, + icon_color=ft.Colors.PRIMARY, tooltip=self._["colors"], items=[PopupColorItem(color=color, name=name) for color, name in colors_list], ), - ft.Text(self._["theme_color"]), + ft.Text(self._["theme_color"], color=ft.Colors.PRIMARY), ], alignment=ft.MainAxisAlignment.START, ), @@ -159,7 +164,7 @@ class LeftNavigationMenu(ft.Column): self.dark_light_icon.icon = ft.Icons.BRIGHTNESS_2_OUTLINED self.dark_light_icon.tooltip = self._["toggle_night_theme"] self.app.settings.user_config["theme_mode"] = "light" - self.page.run_task(self.app.config_manager.save_user_config, self.app.settings.user_config) + page.run_task(self.app.config_manager.save_user_config, self.app.settings.user_config) await self.on_theme_change() page.update() @@ -182,19 +187,13 @@ class NavigationSidebar: def load(self): self._ = self.app.language_manager.language.get("sidebar") self.control_groups = [ - ControlGroup( - icon=ft.Icons.HOME, - label=self._["home"], - index=0, - name="home", - selected_icon=ft.Icons.HOME - ), + ControlGroup(icon=ft.Icons.HOME, label=self._["home"], index=0, name="home", selected_icon=ft.Icons.HOME), ControlGroup( icon=ft.Icons.DASHBOARD, label=self._["recordings"], index=1, name="recordings", - selected_icon=ft.Icons.DASHBOARD_ROUNDED + selected_icon=ft.Icons.DASHBOARD_ROUNDED, ), ControlGroup( icon=ft.Icons.SETTINGS, @@ -208,14 +207,8 @@ class NavigationSidebar: label=self._["storage"], index=3, name="storage", - selected_icon=ft.Icons.DRIVE_FILE_MOVE_OUTLINE - ), - ControlGroup( - icon=ft.Icons.INFO, - label=self._["about"], - index=4, - name="about", - selected_icon=ft.Icons.INFO + selected_icon=ft.Icons.DRIVE_FILE_MOVE_OUTLINE, ), + ControlGroup(icon=ft.Icons.INFO, label=self._["about"], index=4, name="about", selected_icon=ft.Icons.INFO), ] self.selected_control_group = self.control_groups[0] diff --git a/app/ui/themes/theme.py b/app/ui/themes/theme.py index 34af219..b8b9aba 100644 --- a/app/ui/themes/theme.py +++ b/app/ui/themes/theme.py @@ -6,8 +6,8 @@ class PopupColorItem(ft.PopupMenuItem): super().__init__() self.content = ft.Row( controls=[ - ft.Icon(name=ft.Icons.COLOR_LENS_OUTLINED, color=color), - ft.Text(name), + ft.Icon(icon=ft.Icons.COLOR_LENS_OUTLINED, color=color), + ft.Text(name, color=ft.Colors.PRIMARY), ], ) self.on_click = lambda e: self.seed_color_changed(e) @@ -17,6 +17,7 @@ class PopupColorItem(ft.PopupMenuItem): page = e.page page.theme.color_scheme_seed = self.data page.theme.color_scheme = ft.ColorScheme(primary=self.data) + page.theme.use_material3 = True page.update() self.save_theme_color(e) @@ -47,6 +48,7 @@ def create_light_theme(custom_font: str) -> ft.Theme: label_medium=ft.TextStyle(color=ft.Colors.BLACK, font_family=custom_font), label_large=ft.TextStyle(color=ft.Colors.BLACK, font_family=custom_font), ), + use_material3=True, ) @@ -70,4 +72,5 @@ def create_dark_theme(custom_font: str) -> ft.Theme: label_medium=ft.TextStyle(color=ft.Colors.WHITE, font_family=custom_font), label_large=ft.TextStyle(color=ft.Colors.WHITE, font_family=custom_font), ), + use_material3=True, ) diff --git a/app/ui/themes/theme_manager.py b/app/ui/themes/theme_manager.py index 4595121..98c81c1 100644 --- a/app/ui/themes/theme_manager.py +++ b/app/ui/themes/theme_manager.py @@ -31,7 +31,7 @@ class ThemeManager: """Apply initial theme based on saved settings or default to light theme.""" self.page.theme = create_light_theme(self.custom_font) self.page.dark_theme = create_dark_theme(self.custom_font) - + self.theme_color = self.app.settings.user_config.get("theme_color", "blue") await self.update_theme_color(self.theme_color) @@ -39,7 +39,8 @@ class ThemeManager: """Update the current theme color scheme and save it.""" self.page.theme.color_scheme_seed = color self.page.theme.color_scheme = ft.ColorScheme(primary=color) + self.page.theme.use_material3 = True self.page.update() - + self.app.settings.user_config["theme_color"] = color self.page.run_task(self.app.config_manager.save_user_config, self.app.settings.user_config) diff --git a/app/ui/views/about_view.py b/app/ui/views/about_view.py index 3b7ce91..13c328d 100644 --- a/app/ui/views/about_view.py +++ b/app/ui/views/about_view.py @@ -19,7 +19,7 @@ class AboutPage(PageBase): async def load(self): """Load the about page content.""" - self.content_area.clean() + self.content_area.controls.clear() is_mobile = self.app.is_mobile @@ -50,10 +50,12 @@ class AboutPage(PageBase): ft.Column( controls=[ ft.Icon(ft.Icons.VIDEO_LIBRARY, color=ft.Colors.BLUE), - ft.Text(self._["support_platforms"], - size=12, - color=text_color_700, - text_align=ft.TextAlign.CENTER), + ft.Text( + self._["support_platforms"], + size=12, + color=text_color_700, + text_align=ft.TextAlign.CENTER, + ), ], horizontal_alignment=ft.CrossAxisAlignment.CENTER, spacing=5, @@ -62,10 +64,12 @@ class AboutPage(PageBase): ft.Column( controls=[ ft.Icon(ft.Icons.SETTINGS, color=ft.Colors.GREEN), - ft.Text(self._["customize_recording"], - size=12, - color=text_color_700, - text_align=ft.TextAlign.CENTER), + ft.Text( + self._["customize_recording"], + size=12, + color=text_color_700, + text_align=ft.TextAlign.CENTER, + ), ], horizontal_alignment=ft.CrossAxisAlignment.CENTER, spacing=5, @@ -74,10 +78,12 @@ class AboutPage(PageBase): ft.Column( controls=[ ft.Icon(ft.Icons.LIGHTBULB, color=ft.Colors.ORANGE), - ft.Text(self._["open_source"], - size=12, - color=text_color_700, - text_align=ft.TextAlign.CENTER), + ft.Text( + self._["open_source"], + size=12, + color=text_color_700, + text_align=ft.TextAlign.CENTER, + ), ], horizontal_alignment=ft.CrossAxisAlignment.CENTER, spacing=5, @@ -92,10 +98,12 @@ class AboutPage(PageBase): ft.Column( controls=[ ft.Icon(ft.Icons.AUTORENEW, color=ft.Colors.PURPLE), - ft.Text(self._["automatic_transcoding"], - size=12, - color=text_color_700, - text_align=ft.TextAlign.CENTER), + ft.Text( + self._["automatic_transcoding"], + size=12, + color=text_color_700, + text_align=ft.TextAlign.CENTER, + ), ], horizontal_alignment=ft.CrossAxisAlignment.CENTER, spacing=5, @@ -104,10 +112,12 @@ class AboutPage(PageBase): ft.Column( controls=[ ft.Icon(ft.Icons.NOTIFICATIONS_ACTIVE, color=ft.Colors.RED), - ft.Text(self._["status_push"], - size=12, - color=text_color_700, - text_align=ft.TextAlign.CENTER), + ft.Text( + self._["status_push"], + size=12, + color=text_color_700, + text_align=ft.TextAlign.CENTER, + ), ], horizontal_alignment=ft.CrossAxisAlignment.CENTER, spacing=5, @@ -167,8 +177,8 @@ class AboutPage(PageBase): if is_mobile: developer_buttons = ft.Column( controls=[ - ft.ElevatedButton( - text=self._["view_update"], + ft.Button( + content=self._["view_update"], icon=ft.Icons.CODE, on_click=self.open_update_page, width=float("inf"), @@ -177,8 +187,8 @@ class AboutPage(PageBase): padding=10, ), ), - ft.ElevatedButton( - text=self._["view_docs"], + ft.Button( + content=self._["view_docs"], icon=ft.Icons.DESCRIPTION, on_click=self.open_dos_page, width=float("inf"), @@ -187,8 +197,8 @@ class AboutPage(PageBase): padding=10, ), ), - ft.ElevatedButton( - text=self.app.language_manager.language.get("update", {}).get("check_update"), + ft.Button( + content=self.app.language_manager.language.get("update", {}).get("check_update"), icon=ft.Icons.UPDATE, on_click=self._check_for_updates, width=float("inf"), @@ -362,30 +372,31 @@ class AboutPage(PageBase): @staticmethod async def open_update_page(e): url = "https://github.com/ihmily/StreamCap/releases" - e.page.launch_url(url) + await e.page.launch_url(url) @staticmethod async def open_dos_page(e): url = "https://github.com/ihmily/StreamCap/wiki" - e.page.launch_url(url) + await e.page.launch_url(url) async def on_keyboard(self, e: ft.KeyboardEvent): if e.alt and e.key == "H": self.app.dialog_area.content = HelpDialog(self.app) self.app.dialog_area.content.open = True self.app.dialog_area.update() - + async def _check_for_updates(self, _): _ = self.app.language_manager.language.get("update", {}) await self.app.snack_bar.show_snack_bar(_.get("checking_update")) - + update_info = await self.app.update_checker.check_for_updates() - + if update_info.get("has_update", False): await self.app.update_checker.show_update_dialog(update_info) else: if "error" in update_info: await self.app.snack_bar.show_snack_bar( - f"{_.get('update_check_failed')}: {update_info.get('error', '')}") + f"{_.get('update_check_failed')}: {update_info.get('error', '')}" + ) else: await self.app.snack_bar.show_snack_bar(_.get("no_update_available")) diff --git a/app/ui/views/home_view.py b/app/ui/views/home_view.py index db1ac3e..4678bef 100644 --- a/app/ui/views/home_view.py +++ b/app/ui/views/home_view.py @@ -48,7 +48,7 @@ class HomePage(PageBase): src=logo_path, width=120, height=120, - fit=ft.ImageFit.CONTAIN, + fit=ft.BoxFit.CONTAIN, ) current_hour = datetime.now().hour @@ -63,7 +63,7 @@ class HomePage(PageBase): controls=[ ft.Container( content=logo, - alignment=ft.alignment.center, + alignment=ft.alignment.Alignment.CENTER, margin=ft.margin.only(top=30, bottom=10), ), ft.Text( @@ -75,15 +75,17 @@ class HomePage(PageBase): ft.Text( self._["tagline"], size=18, - color=(ft.Colors.BLACK87 if self.app.page.theme_mode == ft.ThemeMode.LIGHT - else ft.Colors.WHITE70), + color=( + ft.Colors.BLACK87 if self.app.page.theme_mode == ft.ThemeMode.LIGHT else ft.Colors.WHITE70 + ), text_align=ft.TextAlign.CENTER, ), ft.Text( self._["version"] + ":" + self.app.about.about_config["version_updates"][0]["version"], size=14, - color=(ft.Colors.BLACK54 if self.app.page.theme_mode == ft.ThemeMode.LIGHT - else ft.Colors.WHITE60), + color=( + ft.Colors.BLACK54 if self.app.page.theme_mode == ft.ThemeMode.LIGHT else ft.Colors.WHITE60 + ), text_align=ft.TextAlign.CENTER, ), ], @@ -91,7 +93,7 @@ class HomePage(PageBase): alignment=ft.MainAxisAlignment.CENTER, spacing=10, ), - alignment=ft.alignment.center, + alignment=ft.alignment.Alignment.CENTER, padding=ft.padding.only(bottom=20), ) @@ -161,13 +163,13 @@ class HomePage(PageBase): width=button_width, height=button_height, ), - alignment=ft.alignment.center, + alignment=ft.alignment.Alignment.CENTER, ), ], horizontal_alignment=ft.CrossAxisAlignment.CENTER, spacing=10, ), - alignment=ft.alignment.center, + alignment=ft.alignment.Alignment.CENTER, padding=ft.padding.only(bottom=20), ) else: @@ -235,13 +237,13 @@ class HomePage(PageBase): horizontal_alignment=ft.CrossAxisAlignment.CENTER, alignment=ft.MainAxisAlignment.CENTER, ), - alignment=ft.alignment.center, + alignment=ft.alignment.Alignment.CENTER, padding=ft.padding.only(bottom=20), ) @staticmethod - def create_action_button(text: str, icon: ft.Icons, color: ft.Colors, on_click: callable, width=180, height=60): - return ft.ElevatedButton( + def create_action_button(text: str, icon: ft.IconData, color: ft.Colors, on_click: callable, width=180, height=60): + return ft.Button( content=ft.Row( controls=[ ft.Icon(icon, color=ft.Colors.WHITE), @@ -267,7 +269,7 @@ class HomePage(PageBase): ) def create_announcements_area(self): - def create_announcement_card(title: str, content: str, icon: ft.Icons, color: ft.Colors): + def create_announcement_card(title: str, content: str, icon: ft.IconData, color: ft.Colors): return ft.Card( content=ft.Container( content=ft.Column( @@ -287,8 +289,11 @@ class HomePage(PageBase): content=ft.Text( content, size=14, - color=(ft.Colors.BLACK87 if self.app.page.theme_mode == ft.ThemeMode.LIGHT - else ft.Colors.WHITE70), + color=( + ft.Colors.BLACK87 + if self.app.page.theme_mode == ft.ThemeMode.LIGHT + else ft.Colors.WHITE70 + ), ), margin=ft.margin.only(left=34), ), @@ -372,15 +377,15 @@ class HomePage(PageBase): if total_recordings > 0: sorted_recordings = sorted( self.app.record_manager.recordings, - key=lambda r: r.last_updated if hasattr(r, 'last_updated') else 0, - reverse=True + key=lambda r: r.last_updated if hasattr(r, "last_updated") else 0, + reverse=True, )[-3:][::-1] for rec in sorted_recordings: status_icon = ft.Icons.CIRCLE status_color = ft.Colors.GREY - if hasattr(rec, 'status'): + if hasattr(rec, "status"): if rec.status == "recording": status_icon = ft.Icons.CIRCLE status_color = ft.Colors.GREEN @@ -399,7 +404,7 @@ class HomePage(PageBase): controls=[ ft.Icon(status_icon, color=status_color, size=16), ft.Text( - rec.streamer_name if hasattr(rec, 'streamer_name') else "未命名录制", + rec.streamer_name if hasattr(rec, "streamer_name") else "未命名录制", size=14, overflow=ft.TextOverflow.ELLIPSIS, ), @@ -555,8 +560,11 @@ class HomePage(PageBase): description, size=13, text_align=ft.TextAlign.CENTER, - color=(ft.Colors.BLACK54 if self.app.page.theme_mode == ft.ThemeMode.LIGHT - else ft.Colors.WHITE70), + color=( + ft.Colors.BLACK54 + if self.app.page.theme_mode == ft.ThemeMode.LIGHT + else ft.Colors.WHITE70 + ), ), ], spacing=8, @@ -564,7 +572,7 @@ class HomePage(PageBase): alignment=ft.MainAxisAlignment.CENTER, ), padding=ft.padding.all(15), - alignment=ft.alignment.center, + alignment=ft.alignment.Alignment.CENTER, width=None if is_mobile else 220, expand=is_mobile, ), @@ -650,7 +658,7 @@ class HomePage(PageBase): ], horizontal_alignment=ft.CrossAxisAlignment.START, ), - alignment=ft.alignment.bottom_left, + alignment=ft.alignment.Alignment.BOTTOM_LEFT, padding=ft.padding.only(left=20, right=20, bottom=30), ) diff --git a/app/ui/views/login_view.py b/app/ui/views/login_view.py index cf53561..5850d1c 100644 --- a/app/ui/views/login_view.py +++ b/app/ui/views/login_view.py @@ -7,7 +7,6 @@ from ...utils.logger import logger class LoginPage: - def __init__(self, page: ft.Page, auth_manager: AuthManager, on_login_success: Callable): self.page = page self.auth_manager = auth_manager @@ -24,7 +23,7 @@ class LoginPage: autofocus=True, width=320, border_radius=8, - prefix_icon=ft.icons.PERSON, + prefix_icon=ft.Icons.PERSON, focused_border_color="#0078d4", focused_color="#0078d4", border_color="#d0d0d0", @@ -39,7 +38,7 @@ class LoginPage: can_reveal_password=True, width=320, border_radius=8, - prefix_icon=ft.icons.LOCK_OUTLINE, + prefix_icon=ft.Icons.LOCK_OUTLINE, focused_border_color="#0078d4", focused_color="#0078d4", border_color="#d0d0d0", @@ -48,7 +47,7 @@ class LoginPage: label_style=ft.TextStyle(color="#666666"), ) - self.login_button = ft.ElevatedButton( + self.login_button = ft.Button( text=self._["login_button"], width=320, on_click=self.handle_login, @@ -57,13 +56,13 @@ class LoginPage: color="#ffffff", bgcolor="#0078d4", elevation=0, - padding=15, + padding=ft.padding.symmetric(horizontal=10, vertical=4), animation_duration=300, ), ) self.error_text = ft.Text( - color=ft.colors.RED_500, + color=ft.Colors.RED_500, size=14, visible=False, ) @@ -72,14 +71,14 @@ class LoginPage: src="/icons/loading-animation.png", width=80, height=80, - fit=ft.ImageFit.CONTAIN, + fit=ft.BoxFit.CONTAIN, ) login_card_content = ft.Column( controls=[ ft.Container( content=self.logo, - alignment=ft.alignment.center, + alignment=ft.alignment.Alignment.CENTER, margin=ft.margin.only(bottom=10), ), ft.Text( @@ -102,7 +101,7 @@ class LoginPage: ft.Container( content=self.error_text, margin=ft.margin.only(top=10), - alignment=ft.alignment.center, + alignment=ft.alignment.Alignment.CENTER, ), ft.Container(height=20), self.login_button, @@ -124,19 +123,19 @@ class LoginPage: width=400, height=600, padding=30, - bgcolor=ft.colors.WHITE, + bgcolor=ft.Colors.WHITE, border_radius=12, shadow=ft.BoxShadow( spread_radius=1, blur_radius=15, - color=ft.colors.with_opacity(0.1, "#000000"), + color=ft.Colors.with_opacity(0.1, "#000000"), offset=ft.Offset(0, 4), ), ) self.main_view = ft.Container( content=self.login_card, - alignment=ft.alignment.center, + alignment=ft.alignment.Alignment.CENTER, expand=True, bgcolor="#f0f2f5", ) @@ -162,7 +161,7 @@ class LoginPage: if success: logger.info(f"Login successful: {username}") - await self.page.client_storage.set_async("session_token", token) + await self.page.shared_preferences.set("session_token", token) await self.on_login_success(token) self.page.title = "StreamCap" else: @@ -178,4 +177,4 @@ class LoginPage: self.page.update() def get_view(self) -> ft.Control: - return self.main_view \ No newline at end of file + return self.main_view diff --git a/app/ui/views/recordings_view.py b/app/ui/views/recordings_view.py index 3f5f484..c9b761b 100644 --- a/app/ui/views/recordings_view.py +++ b/app/ui/views/recordings_view.py @@ -35,68 +35,47 @@ class RecordingsPage(PageBase): self._.update(language.get(key, {})) def init(self): - self.loading_indicator = ft.ProgressRing( - width=40, - height=40, - stroke_width=3, - visible=False - ) - + self.loading_indicator = ft.ProgressRing(width=40, height=40, stroke_width=3, visible=False) + if self.is_grid_view: initial_content = ft.GridView( - expand=True, - runs_count=3, - spacing=10, - run_spacing=10, - child_aspect_ratio=2.3, - controls=[] + expand=True, runs_count=3, spacing=10, run_spacing=10, child_aspect_ratio=2.3, controls=[] ) else: - initial_content = ft.Column( - controls=[], - spacing=5, - expand=True - ) - - self.recording_card_area = ft.Container( - content=initial_content, - expand=True - ) + initial_content = ft.Column(controls=[], spacing=5, expand=True) + + self.recording_card_area = ft.Container(content=initial_content, expand=True) self.add_recording_dialog = RecordingDialog(self.app, self.add_recording) self.pubsub_subscribe() async def load(self): """Load the recordings page content.""" self.content_area.controls.extend( - [ - self.create_recordings_title_area(), - self.create_filter_area(), - self.create_recordings_content_area() - ] + [self.create_recordings_title_area(), self.create_filter_area(), self.create_recordings_content_area()] ) self.content_area.update() - + if not self.recording_card_area.content.controls: self.recording_card_area.content.controls.clear() await self.add_record_cards() else: # if cards exist, apply filter await self.apply_filter() - + if self.is_grid_view: await self.recalculate_grid_columns() - + self.page.on_keyboard_event = self.on_keyboard - self.page.on_resized = self.update_grid_layout + self.page.on_resize = self.update_grid_layout def pubsub_subscribe(self): - self.app.page.pubsub.subscribe_topic('add', self.subscribe_add_cards) - self.app.page.pubsub.subscribe_topic('delete_all', self.subscribe_del_all_cards) + self.app.page.pubsub.subscribe_topic("add", self.subscribe_add_cards) + self.app.page.pubsub.subscribe_topic("delete_all", self.subscribe_del_all_cards) async def toggle_view_mode(self, _): self.is_grid_view = not self.is_grid_view current_content = self.recording_card_area.content - current_controls = current_content.controls if hasattr(current_content, 'controls') else [] + current_controls = current_content.controls if hasattr(current_content, "controls") else [] column_width = 350 runs_count = max(1, int(self.page.width / column_width)) @@ -108,65 +87,76 @@ class RecordingsPage(PageBase): spacing=10, run_spacing=10, child_aspect_ratio=2.3, - controls=current_controls + controls=current_controls, ) else: - new_content = ft.Column( - controls=current_controls, - spacing=5, - expand=True - ) + new_content = ft.Column(controls=current_controls, spacing=5, expand=True) self.recording_card_area.content = new_content - self.content_area.clean() + self.content_area.controls.clear() self.content_area.controls.extend( - [ - self.create_recordings_title_area(), - self.create_filter_area(), - self.create_recordings_content_area() - ] + [self.create_recordings_title_area(), self.create_filter_area(), self.create_recordings_content_area()] ) self.content_area.update() - + self.app.settings.user_config["is_grid_view"] = self.is_grid_view self.page.run_task(self.app.config_manager.save_user_config, self.app.settings.user_config) def create_recordings_title_area(self): toggle_view_mode_button = ft.IconButton( icon=ft.Icons.GRID_VIEW if self.is_grid_view else ft.Icons.LIST, + icon_color=ft.Colors.PRIMARY, tooltip=self._["toggle_view"], - on_click=self.toggle_view_mode + on_click=self.toggle_view_mode, ) title_controls = [ ft.Text(self._["recording_list"], theme_style=ft.TextThemeStyle.TITLE_MEDIUM), ft.Container(expand=True), ] - + if not self.app.is_mobile: title_controls.append(toggle_view_mode_button) batch_controls = [ - ft.IconButton(icon=ft.Icons.SEARCH, tooltip=self._["search"], on_click=self.search_on_click), - ft.IconButton(icon=ft.Icons.ADD, tooltip=self._["add_record"], on_click=self.add_recording_on_click), - ft.IconButton(icon=ft.Icons.REFRESH, tooltip=self._["refresh"], on_click=self.refresh_cards_on_click), + ft.IconButton( + icon=ft.Icons.SEARCH, + icon_color=ft.Colors.PRIMARY, + tooltip=self._["search"], + on_click=self.search_on_click, + ), + ft.IconButton( + icon=ft.Icons.ADD, + icon_color=ft.Colors.PRIMARY, + tooltip=self._["add_record"], + on_click=self.add_recording_on_click, + ), + ft.IconButton( + icon=ft.Icons.REFRESH, + icon_color=ft.Colors.PRIMARY, + tooltip=self._["refresh"], + on_click=self.refresh_cards_on_click, + ), ft.IconButton( icon=ft.Icons.PLAY_ARROW, + icon_color=ft.Colors.PRIMARY, tooltip=self._["batch_start"], on_click=self.start_monitor_recordings_on_click, ), ft.IconButton( - icon=ft.Icons.STOP, - tooltip=self._["batch_stop"], - on_click=self.stop_monitor_recordings_on_click + icon=ft.Icons.STOP, + icon_color=ft.Colors.PRIMARY, + tooltip=self._["batch_stop"], + on_click=self.stop_monitor_recordings_on_click, ), ft.IconButton( icon=ft.Icons.DELETE_SWEEP, + icon_color=ft.Colors.PRIMARY, tooltip=self._["batch_delete"], on_click=self.delete_monitor_recordings_on_click, ), ] - + if self.app.is_mobile: first_row = ft.Row( title_controls, @@ -179,9 +169,9 @@ class RecordingsPage(PageBase): ], alignment=ft.MainAxisAlignment.START, spacing=2, - scroll=ft.ScrollMode.HIDDEN + scroll=ft.ScrollMode.HIDDEN, ) - + return ft.Column( [first_row, second_row], spacing=5, @@ -191,189 +181,184 @@ class RecordingsPage(PageBase): [*title_controls, *batch_controls], alignment=ft.MainAxisAlignment.START, ) - + def create_filter_area(self): """Create the filter area""" filter_buttons = [ ft.Text(self._["status_filter"] + ":" if not self.app.is_mobile else self._["filter"] + ":", size=14), - ft.ElevatedButton( + ft.Button( self._["filter_all"], on_click=self.filter_all_on_click, - bgcolor=ft.Colors.BLUE if self.current_filter == "all" else None, + bgcolor=ft.Colors.PRIMARY if self.current_filter == "all" else None, color=ft.Colors.WHITE if self.current_filter == "all" else None, style=ft.ButtonStyle( shape=ft.RoundedRectangleBorder(radius=5), + padding=ft.padding.symmetric(horizontal=10, vertical=4), ), ), - ft.ElevatedButton( + ft.Button( self._["filter_recording"], on_click=self.filter_recording_on_click, bgcolor=ft.Colors.GREEN if self.current_filter == "recording" else None, color=ft.Colors.WHITE if self.current_filter == "recording" else None, style=ft.ButtonStyle( shape=ft.RoundedRectangleBorder(radius=5), + padding=ft.padding.symmetric(horizontal=10, vertical=4), ), ), - ft.ElevatedButton( + ft.Button( self._["filter_living"], on_click=self.filter_living_on_click, bgcolor=ft.Colors.BLUE if self.current_filter == "living" else None, color=ft.Colors.WHITE if self.current_filter == "living" else None, style=ft.ButtonStyle( shape=ft.RoundedRectangleBorder(radius=5), + padding=ft.padding.symmetric(horizontal=10, vertical=4), ), ), - ft.ElevatedButton( + ft.Button( self._["filter_offline"], on_click=self.filter_offline_on_click, bgcolor=ft.Colors.AMBER if self.current_filter == "offline" else None, color=ft.Colors.WHITE if self.current_filter == "offline" else None, style=ft.ButtonStyle( shape=ft.RoundedRectangleBorder(radius=5), + padding=ft.padding.symmetric(horizontal=10, vertical=4), ), ), - ft.ElevatedButton( + ft.Button( self._["filter_error"], on_click=self.filter_error_on_click, bgcolor=ft.Colors.RED if self.current_filter == "error" else None, color=ft.Colors.WHITE if self.current_filter == "error" else None, style=ft.ButtonStyle( shape=ft.RoundedRectangleBorder(radius=5), + padding=ft.padding.symmetric(horizontal=10, vertical=4), ), ), - ft.ElevatedButton( + ft.Button( self._["filter_stopped"], on_click=self.filter_stopped_on_click, bgcolor=ft.Colors.GREY if self.current_filter == "stopped" else None, color=ft.Colors.WHITE if self.current_filter == "stopped" else None, style=ft.ButtonStyle( shape=ft.RoundedRectangleBorder(radius=5), + padding=ft.padding.symmetric(horizontal=10, vertical=4), ), ), ] - + platforms = {} for recording in self.app.record_manager.recordings: if recording.platform and recording.platform_key: platforms[recording.platform_key] = recording.platform - - platform_options = [ - ft.dropdown.Option(key="all", text=self._["filter_all"]) - ] - + + platform_options = [ft.dropdown.DropdownOption(key="all", text=self._["filter_all"])] + for key, name in platforms.items(): - platform_options.append(ft.dropdown.Option(key=key, text=name)) - + platform_options.append(ft.dropdown.DropdownOption(key=key, text=name)) + current_platform_keys = ["all"] + list(platforms.keys()) if self.current_platform_filter not in current_platform_keys: self.current_platform_filter = "all" - + platform_dropdown = ft.Dropdown( options=platform_options, value=self.current_platform_filter, - on_change=self.on_platform_dropdown_change, + on_select=self.on_platform_dropdown_change, + hover_color=ft.Colors.PRIMARY, width=120, text_size=14, content_padding=ft.padding.only(top=8, bottom=8, left=10, right=10), border_radius=5, border_color=ft.Colors.OUTLINE, - focused_border_color=ft.colors.PRIMARY, + focused_border_color=ft.Colors.PRIMARY, dense=True, ) if len(current_platform_keys) > 8: platform_dropdown.menu_height = 320 - + platform_filter_area = ft.Row( - [ - ft.Text(self._["platform_filter"] + ":", size=14), - platform_dropdown - ], + [ft.Text(self._["platform_filter"] + ":", size=14), platform_dropdown], spacing=8, vertical_alignment=ft.CrossAxisAlignment.CENTER, ) - + if self.app.is_mobile: status_filter_row = ft.Row( filter_buttons, alignment=ft.MainAxisAlignment.START, spacing=3, vertical_alignment=ft.CrossAxisAlignment.CENTER, - scroll=ft.ScrollMode.HIDDEN + scroll=ft.ScrollMode.HIDDEN, ) - + platform_filter_row = ft.Row( [platform_filter_area], alignment=ft.MainAxisAlignment.START, spacing=5, vertical_alignment=ft.CrossAxisAlignment.CENTER, ) - + return ft.Column( - [ - status_filter_row, - platform_filter_row - ], + [status_filter_row, platform_filter_row], spacing=5, alignment=ft.MainAxisAlignment.START, horizontal_alignment=ft.CrossAxisAlignment.START, ) else: return ft.Row( - [ - *filter_buttons, - ft.Container(expand=True), - platform_filter_area - ], + [*filter_buttons, ft.Container(expand=True), platform_filter_area], alignment=ft.MainAxisAlignment.START, spacing=5, vertical_alignment=ft.CrossAxisAlignment.CENTER, ) - + async def filter_all_on_click(self, _): self.current_filter = "all" await self.apply_filter() - + async def filter_recording_on_click(self, _): self.current_filter = "recording" await self.apply_filter() - + async def filter_living_on_click(self, _): self.current_filter = "living" await self.apply_filter() - + async def filter_error_on_click(self, _): self.current_filter = "error" await self.apply_filter() - + async def filter_offline_on_click(self, _): self.current_filter = "offline" await self.apply_filter() - + async def filter_stopped_on_click(self, _): self.current_filter = "stopped" await self.apply_filter() - + async def apply_filter(self): if len(self.content_area.controls) > 1: self.content_area.controls[1] = self.create_filter_area() else: self.content_area.controls.append(self.create_filter_area()) - + cards_obj = self.app.record_card_manager.cards_obj recordings = self.app.record_manager.recordings - + for recording in recordings: card_info = cards_obj.get(recording.rec_id) if not card_info: continue - + status_visible = RecordingFilters.get_status_filter_result(recording, self.current_filter) platform_visible = RecordingFilters.get_platform_filter_result(recording, self.current_platform_filter) - + visible = status_visible and platform_visible card_info["card"].visible = visible - + self.content_area.update() self.recording_card_area.update() @@ -398,9 +383,9 @@ class RecordingsPage(PageBase): for rec in recordings if lower_query in str(rec.to_dict()).lower() or lower_query in rec.display_title } - + filtered_ids = set() - + for rec_id in search_ids: recording = self.app.record_manager.find_recording_by_id(rec_id) if not recording: @@ -422,23 +407,20 @@ class RecordingsPage(PageBase): expand=True, controls=[ ft.Divider(height=1), - ft.Container( - content=self.loading_indicator, - alignment=ft.alignment.center - ), + ft.Container(content=self.loading_indicator, alignment=ft.alignment.Alignment.CENTER), self.recording_card_area, ], scroll=ft.ScrollMode.AUTO if not self.app.is_mobile else ft.ScrollMode.HIDDEN, ) async def add_record_cards(self): - + self.loading_indicator.visible = True self.loading_indicator.update() cards_to_create = [] existing_cards = [] - + for recording in self.app.record_manager.recordings: if recording.rec_id not in self.app.record_card_manager.cards_obj: cards_to_create.append(recording) @@ -446,24 +428,21 @@ class RecordingsPage(PageBase): existing_card = self.app.record_card_manager.cards_obj[recording.rec_id]["card"] existing_card.visible = True existing_cards.append(existing_card) - + async def create_card_with_time_range(_recording: Recording): _card = await self.app.record_card_manager.create_card(_recording) _recording.scheduled_time_range = await self.app.record_manager.get_scheduled_time_range( _recording.scheduled_start_time, _recording.monitor_hours ) return _card, _recording - + if cards_to_create: - results = await asyncio.gather(*[ - create_card_with_time_range(recording) - for recording in cards_to_create - ]) - + results = await asyncio.gather(*[create_card_with_time_range(recording) for recording in cards_to_create]) + for card, recording in results: self.recording_card_area.content.controls.append(card) self.app.record_card_manager.cards_obj[recording.rec_id]["card"] = card - + if existing_cards: for card in existing_cards: self.recording_card_area.content.controls.append(card) @@ -471,13 +450,12 @@ class RecordingsPage(PageBase): self.loading_indicator.visible = False self.loading_indicator.update() self.recording_card_area.update() - + if not RecordingManager.is_periodic_task_running(): self.page.run_task( - self.app.record_manager.setup_periodic_live_check, - self.app.record_manager.loop_time_seconds + self.app.record_manager.setup_periodic_live_check, self.app.record_manager.loop_time_seconds ) - + await self.apply_filter() async def show_all_cards(self): @@ -485,13 +463,13 @@ class RecordingsPage(PageBase): for card in cards_obj.values(): card["card"].visible = True self.recording_card_area.update() - + await self.apply_filter() async def add_recording(self, recordings_info): user_config = self.app.settings.user_config logger.info(f"Add items: {len(recordings_info)}") - + new_recordings = [] for recording_info in recordings_info: if recording_info.get("record_format"): @@ -542,6 +520,7 @@ class RecordingsPage(PageBase): new_recordings.append(recording) if new_recordings: + async def create_card_with_time_range(rec): _card = await self.app.record_card_manager.create_card(rec) rec.scheduled_time_range = await self.app.record_manager.get_scheduled_time_range( @@ -549,18 +528,15 @@ class RecordingsPage(PageBase): ) return _card, rec - results = await asyncio.gather(*[ - create_card_with_time_range(rec) - for rec in new_recordings - ]) + results = await asyncio.gather(*[create_card_with_time_range(rec) for rec in new_recordings]) for card, recording in results: self.recording_card_area.content.controls.append(card) self.app.record_card_manager.cards_obj[recording.rec_id]["card"] = card self.app.page.pubsub.send_others_on_topic("add", recording) - + self.recording_card_area.update() - + self.content_area.controls[1] = self.create_filter_area() self.content_area.update() @@ -577,10 +553,10 @@ class RecordingsPage(PageBase): await self.add_recording_dialog.show_dialog() async def refresh_cards_on_click(self, _e): - + self.loading_indicator.visible = True self.loading_indicator.update() - + cards_obj = self.app.record_card_manager.cards_obj recordings = self.app.record_manager.recordings selected_cards = self.app.record_card_manager.selected_cards @@ -600,13 +576,13 @@ class RecordingsPage(PageBase): cards_obj.pop(card_key, None) self.recording_card_area.controls.remove(card["card"]) await self.show_all_cards() - + self.content_area.controls[1] = self.create_filter_area() self.content_area.update() - + self.loading_indicator.visible = False self.loading_indicator.update() - + await self.app.snack_bar.show_snack_bar(self._["refresh_success_tip"], bgcolor=ft.Colors.GREEN) async def start_monitor_recordings_on_click(self, _): @@ -636,7 +612,7 @@ class RecordingsPage(PageBase): self.content_area.controls[1] = self.create_filter_area() self.content_area.update() - + self.recording_card_area.update() await self.app.snack_bar.show_snack_bar( self._["delete_recording_success_tip"], bgcolor=ft.Colors.GREEN, duration=2000 @@ -651,8 +627,8 @@ class RecordingsPage(PageBase): title=ft.Text(self._["confirm"]), content=ft.Text(tips), actions=[ - ft.TextButton(text=self._["cancel"], on_click=close_dialog), - ft.TextButton(text=self._["sure"], on_click=confirm_dlg), + ft.TextButton(content=self._["cancel"], on_click=close_dialog), + ft.TextButton(content=self._["sure"], on_click=confirm_dlg), ], actions_alignment=ft.MainAxisAlignment.END, modal=False, @@ -666,9 +642,9 @@ class RecordingsPage(PageBase): self.recording_card_area.content.controls.clear() self.recording_card_area.update() self.app.record_card_manager.cards_obj = {} - + self.current_platform_filter = "all" - + self.content_area.controls[1] = self.create_filter_area() self.content_area.update() @@ -677,27 +653,27 @@ class RecordingsPage(PageBase): async def subscribe_add_cards(self, _, recording: Recording): """Handle the subscription of adding cards from other clients""" - + self.loading_indicator.visible = True self.loading_indicator.update() - + if recording.rec_id not in self.app.record_card_manager.cards_obj: card = await self.app.record_card_manager.create_card(recording, subscribe_add_cards=True) recording.scheduled_time_range = await self.app.record_manager.get_scheduled_time_range( recording.scheduled_start_time, recording.monitor_hours ) - + self.recording_card_area.content.controls.append(card) self.app.record_card_manager.cards_obj[recording.rec_id]["card"] = card - - self.loading_indicator.visible = False - self.loading_indicator.update() - + self.recording_card_area.update() - + self.content_area.controls[1] = self.create_filter_area() self.content_area.update() + self.loading_indicator.visible = False + self.loading_indicator.update() + async def update_grid_layout(self, _): self.page.run_task(self.recalculate_grid_columns) @@ -717,13 +693,13 @@ class RecordingsPage(PageBase): if isinstance(self.recording_card_area.content, ft.GridView): grid_view = self.recording_card_area.content grid_view.runs_count = runs_count - + grid_view.child_aspect_ratio = child_aspect_ratio - + if self.app.is_mobile: grid_view.spacing = 5 grid_view.run_spacing = 5 - + grid_view.update() async def on_keyboard(self, e: ft.KeyboardEvent): diff --git a/app/ui/views/settings_view.py b/app/ui/views/settings_view.py index fc702f3..73052b2 100644 --- a/app/ui/views/settings_view.py +++ b/app/ui/views/settings_view.py @@ -39,7 +39,7 @@ class SettingsPage(PageBase): self.page.on_keyboard_event = self.on_keyboard async def load(self): - self.content_area.clean() + self.content_area.controls.clear() language = self.app.language_manager.language self._ = language["settings_page"] | language["video_quality"] | language["base"] self.tab_recording = self.create_recording_settings_tab() @@ -48,21 +48,35 @@ class SettingsPage(PageBase): self.tab_accounts = self.create_accounts_settings_tab() self.page.on_keyboard_event = self.on_keyboard - tabs = [ - ft.Tab(text=self._["recording_settings"], content=self.tab_recording), - ft.Tab(text=self._["push_settings"], content=self.tab_push), - ft.Tab(text=self._["cookies_settings"], content=self.tab_cookies), - ft.Tab(text=self._["accounts_settings"], content=self.tab_accounts), + tab_labels = [ + ft.Tab(label=self._["recording_settings"]), + ft.Tab(label=self._["push_settings"]), + ft.Tab(label=self._["cookies_settings"]), + ft.Tab(label=self._["accounts_settings"]), ] - + tab_contents = [ + self.tab_recording, + self.tab_push, + self.tab_cookies, + self.tab_accounts, + ] + if self.app.page.web: self.tab_security = self.create_security_settings_tab() - tabs.append(ft.Tab(text=self._["security_settings"], content=self.tab_security)) + tab_labels.append(ft.Tab(label=self._["security_settings"])) + tab_contents.append(self.tab_security) settings_tabs = ft.Tabs( + content=ft.Column( + [ + ft.TabBar(tabs=tab_labels), + ft.TabBarView(controls=tab_contents, expand=True), + ], + expand=True, + ), + length=len(tab_labels), selected_index=0, animation_duration=300, - tabs=tabs, expand=True, ) @@ -96,11 +110,7 @@ class SettingsPage(PageBase): self.app.complete_page.update() def init_unsaved_changes(self): - self.has_unsaved_changes = { - "user_config": False, - "cookies_config": False, - "accounts_config": False - } + self.has_unsaved_changes = {"user_config": False, "cookies_config": False, "accounts_config": False} def load_language(self): self.default_language, default_language_code = list(self.language_option.items())[0] @@ -140,8 +150,8 @@ class SettingsPage(PageBase): title=ft.Text(self._["confirm"]), content=ft.Text(self._["query_restore_config_tip"]), actions=[ - ft.TextButton(text=self._["cancel"], on_click=close_dialog), - ft.TextButton(text=self._["sure"], on_click=confirm_dlg), + ft.TextButton(content=self._["cancel"], on_click=close_dialog), + ft.TextButton(content=self._["sure"], on_click=confirm_dlg), ], actions_alignment=ft.MainAxisAlignment.END, modal=False, @@ -155,15 +165,17 @@ class SettingsPage(PageBase): """Handle changes in any input field and trigger auto-save.""" key = e.control.data if isinstance(e.control, (ft.Switch, ft.Checkbox)): - self.user_config[key] = e.data.lower() == "true" + value = e.data + self.user_config[key] = value if isinstance(value, bool) else str(value).lower() == "true" else: + # For other controls, e.data is string self.user_config[key] = e.data - + if key in ["folder_name_platform", "folder_name_author", "folder_name_time", "folder_name_title"]: for recording in self.app.record_manager.recordings: recording.recording_dir = None self.page.run_task(self.app.record_manager.persist_recordings) - + if key == "language": self.load_language() self.app.language_manager.load() @@ -173,14 +185,14 @@ class SettingsPage(PageBase): if key == "loop_time_seconds": self.app.record_manager.initialize_dynamic_state() self.page.run_task(self.delay_handler.start_task_timer, self.save_user_config_after_delay, None) - self.has_unsaved_changes['user_config'] = True + self.has_unsaved_changes["user_config"] = True def on_cookies_change(self, e): """Handle changes in any input field and trigger auto-save.""" key = e.control.data self.cookies_config[key] = e.data self.page.run_task(self.delay_handler.start_task_timer, self.save_cookies_after_delay, None) - self.has_unsaved_changes['cookies_config'] = True + self.has_unsaved_changes["cookies_config"] = True def on_accounts_change(self, e): """Handle changes in any input field and trigger auto-save.""" @@ -191,27 +203,27 @@ class SettingsPage(PageBase): self.accounts_config[k1][k2] = e.data self.page.run_task(self.delay_handler.start_task_timer, self.save_accounts_after_delay, None) - self.has_unsaved_changes['accounts_config'] = True + self.has_unsaved_changes["accounts_config"] = True async def save_user_config_after_delay(self, delay): await asyncio.sleep(delay) - if self.has_unsaved_changes['user_config']: + if self.has_unsaved_changes["user_config"]: await self.config_manager.save_user_config(self.user_config) async def save_cookies_after_delay(self, delay): await asyncio.sleep(delay) - if self.has_unsaved_changes['cookies_config']: + if self.has_unsaved_changes["cookies_config"]: await self.config_manager.save_cookies_config(self.cookies_config) async def save_accounts_after_delay(self, delay): await asyncio.sleep(delay) - if self.has_unsaved_changes['accounts_config']: + if self.has_unsaved_changes["accounts_config"]: await self.config_manager.save_accounts_config(self.accounts_config) def get_video_save_path(self): live_save_path = self.get_config_value("live_save_path") if not live_save_path: - live_save_path = os.path.join(self.app.run_path, 'downloads') + live_save_path = os.path.join(self.app.run_path, "downloads") return live_save_path @staticmethod @@ -221,7 +233,7 @@ class SettingsPage(PageBase): def create_recording_settings_tab(self): """Create UI elements for recording settings.""" is_mobile = self.app.is_mobile - + return ft.Column( [ self.create_setting_group( @@ -232,6 +244,7 @@ class SettingsPage(PageBase): self._["restore_defaults"], ft.IconButton( icon=ft.Icons.RESTORE_OUTLINED, + icon_color=ft.Colors.PRIMARY, icon_size=32, tooltip=self._["restore_defaults"], on_click=self.restore_default_config, @@ -241,11 +254,12 @@ class SettingsPage(PageBase): self._["program_language"], ft.Dropdown( options=[ - ft.dropdown.Option(key=k, text=self._[k]) for k, v in self.language_option.items() + ft.dropdown.DropdownOption(key=k, text=self._[k]) + for k, v in self.language_option.items() ], value=self.get_config_value("language", self.default_language), width=200, - on_change=self.on_change, + on_select=self.on_change, data="language", tooltip=self._["switch_language"], ), @@ -328,22 +342,24 @@ class SettingsPage(PageBase): self.create_setting_row( self._["video_record_format"], ft.Dropdown( - options=[ft.dropdown.Option(i) for i in self.get_supported_record_format()], + options=[ft.dropdown.DropdownOption(i) for i in self.get_supported_record_format()], value=self.get_config_value("video_format", VideoFormat.TS), width=200, data="video_format", - on_change=self.on_change, + on_select=self.on_change, tooltip=self._["switch_video_format"], ), ), self.create_setting_row( self._["recording_quality"], ft.Dropdown( - options=[ft.dropdown.Option(i, text=self._[i]) for i in VideoQuality.get_qualities()], + options=[ + ft.dropdown.DropdownOption(i, text=self._[i]) for i in VideoQuality.get_qualities() + ], value=self.get_config_value("record_quality", VideoQuality.OD), width=200, data="record_quality", - on_change=self.on_change, + on_select=self.on_change, tooltip=self._["switch_recording_quality"], ), ), @@ -375,11 +391,11 @@ class SettingsPage(PageBase): self.create_setting_row( self._["default_live_source"], ft.Dropdown( - options=[ft.dropdown.Option(i) for i in ['HLS', 'FLV']], - value=self.get_config_value("default_live_source", 'FLV'), + options=[ft.dropdown.DropdownOption(i) for i in ["HLS", "FLV"]], + value=self.get_config_value("default_live_source", "FLV"), width=200, data="default_live_source", - on_change=self.on_change, + on_select=self.on_change, tooltip=self._["default_live_source_tip"], ), ), @@ -467,7 +483,7 @@ class SettingsPage(PageBase): width=100, data="platform_max_concurrent_requests", on_change=self.on_change, - hint_text=self._["platform_max_concurrent_requests_tip"] + hint_text=self._["platform_max_concurrent_requests_tip"], ), ), self.create_setting_row( @@ -476,7 +492,7 @@ class SettingsPage(PageBase): value=self.get_config_value("check_live_on_browser_refresh", True), data="check_live_on_browser_refresh", on_change=self.on_change, - tooltip=self._['check_live_on_browser_refresh_tip'] + tooltip=self._["check_live_on_browser_refresh_tip"], ), ), ], @@ -490,7 +506,7 @@ class SettingsPage(PageBase): def create_push_settings_tab(self): """Create UI elements for push configuration.""" is_mobile = self.app.is_mobile - + return ft.Column( [ self.create_setting_group( @@ -754,10 +770,13 @@ class SettingsPage(PageBase): self.create_setting_row( self._["bark_interrupt_level"], ft.Dropdown( - options=[ft.dropdown.Option("active"), ft.dropdown.Option("passive")], + options=[ + ft.dropdown.DropdownOption("active"), + ft.dropdown.DropdownOption("passive"), + ], value=self.get_config_value("bark_interrupt_level"), width=200, - on_change=self.on_change, + on_select=self.on_change, data="bark_interrupt_level", ), ), @@ -846,30 +865,14 @@ class SettingsPage(PageBase): def create_push_channels_layout(self): controls = [ - self.create_channel_switch_container( - self._["dingtalk"], ft.Icons.BUSINESS_CENTER, "dingtalk_enabled" - ), - self.create_channel_switch_container( - self._["wechat"], ft.Icons.WECHAT, "wechat_enabled" - ), - self.create_channel_switch_container( - self._["feishu"], ft.Icons.BOOK, "feishu_enabled" - ), - self.create_channel_switch_container( - self._["serverchan"], ft.Icons.CLOUD_OUTLINED, "serverchan_enabled" - ), - self.create_channel_switch_container( - self._["email"], ft.Icons.EMAIL, "email_enabled" - ), - self.create_channel_switch_container( - "Bark", ft.Icons.NOTIFICATIONS_ACTIVE, "bark_enabled" - ), - self.create_channel_switch_container( - "Ntfy", ft.Icons.NOTIFICATIONS, "ntfy_enabled" - ), - self.create_channel_switch_container( - self._["telegram"], ft.Icons.SMS, "telegram_enabled" - ), + self.create_channel_switch_container(self._["dingtalk"], ft.Icons.BUSINESS_CENTER, "dingtalk_enabled"), + self.create_channel_switch_container(self._["wechat"], ft.Icons.WECHAT, "wechat_enabled"), + self.create_channel_switch_container(self._["feishu"], ft.Icons.BOOK, "feishu_enabled"), + self.create_channel_switch_container(self._["serverchan"], ft.Icons.CLOUD_OUTLINED, "serverchan_enabled"), + self.create_channel_switch_container(self._["email"], ft.Icons.EMAIL, "email_enabled"), + self.create_channel_switch_container("Bark", ft.Icons.NOTIFICATIONS_ACTIVE, "bark_enabled"), + self.create_channel_switch_container("Ntfy", ft.Icons.NOTIFICATIONS, "ntfy_enabled"), + self.create_channel_switch_container(self._["telegram"], ft.Icons.SMS, "telegram_enabled"), ] if self.app.is_mobile: @@ -900,7 +903,7 @@ class SettingsPage(PageBase): def create_cookies_settings_tab(self): """Create UI elements for push configuration.""" is_mobile = self.app.is_mobile - + platforms = [ "douyin", "tiktok", @@ -970,7 +973,7 @@ class SettingsPage(PageBase): def create_accounts_settings_tab(self): """Create UI elements for platform accounts configuration.""" is_mobile = self.app.is_mobile - + return ft.Column( [ self.create_setting_group( @@ -1034,11 +1037,11 @@ class SettingsPage(PageBase): self.create_setting_row( self._["twitcasting_account_type"], ft.Dropdown( - options=[ft.dropdown.Option("Default"), ft.dropdown.Option("Twitter")], + options=[ft.dropdown.DropdownOption("Default"), ft.dropdown.DropdownOption("Twitter")], value=self.get_accounts_value("twitcasting_account_type", "Default"), width=500, data="twitcasting_account_type", - on_change=self.on_accounts_change, + on_select=self.on_accounts_change, tooltip=self._["switch_account_type"], ), ), @@ -1095,7 +1098,7 @@ class SettingsPage(PageBase): data="folder_name_title", ), ] - + if self.app.is_mobile: checkbox_grid = ft.Column( [ @@ -1104,14 +1107,14 @@ class SettingsPage(PageBase): ], spacing=5, ) - + return ft.Column( [ ft.Text(label, text_align=ft.TextAlign.LEFT, weight=ft.FontWeight.BOLD), ft.Container( content=checkbox_grid, margin=ft.margin.only(top=5, bottom=10), - ) + ), ], spacing=5, alignment=ft.MainAxisAlignment.START, @@ -1145,7 +1148,7 @@ class SettingsPage(PageBase): def create_channel_config(channel_name, settings): """Helper method to create expandable configurations for each channel.""" return ft.ExpansionTile( - initially_expanded=False, + expanded=False, title=ft.Text(channel_name, size=14, weight=ft.FontWeight.BOLD), controls=[ft.Container(content=ft.Column(settings, spacing=5), padding=10)], tile_padding=0, @@ -1156,7 +1159,7 @@ class SettingsPage(PageBase): """Helper method to group settings under a title.""" padding = 5 if is_mobile else 10 margin = 5 if is_mobile else 10 - + card = ft.Card( content=ft.Container( content=ft.Column( @@ -1172,7 +1175,7 @@ class SettingsPage(PageBase): elevation=5, margin=margin, ) - + if is_mobile: return ft.Container( content=card, @@ -1188,29 +1191,25 @@ class SettingsPage(PageBase): def create_setting_row(self, label, control): """Helper method to create a row for each setting.""" - if hasattr(control, 'on_focus'): + if hasattr(control, "on_focus"): control.on_focus = lambda e: self.set_focused_control(e.control) - + if self.app.is_mobile: if isinstance(control, (ft.Switch, ft.Checkbox, ft.IconButton)): return ft.Row( - [ - ft.Text(label), - ft.Container(expand=True), - control - ], + [ft.Text(label), ft.Container(expand=True), control], alignment=ft.MainAxisAlignment.SPACE_BETWEEN, vertical_alignment=ft.CrossAxisAlignment.CENTER, width=float("inf"), ) - - if hasattr(control, 'width') and control.width and control.width > 250: + + if hasattr(control, "width") and control.width and control.width > 250: control.width = 250 - + if isinstance(control, (ft.TextField, ft.Dropdown)): control.width = float("inf") control.expand = True - + return ft.Column( [ ft.Text(label, text_align=ft.TextAlign.LEFT), @@ -1219,7 +1218,7 @@ class SettingsPage(PageBase): margin=ft.margin.only(top=5, bottom=10), expand=True, width=float("inf"), - ) + ), ], spacing=0, alignment=ft.MainAxisAlignment.START, @@ -1234,33 +1233,36 @@ class SettingsPage(PageBase): ) def pick_folder(self, label, control): - def picked_folder(e: ft.FilePickerResultEvent): - path = e.path + async def pick_folder_click(_): + if self.app.page.web: + await self.app.snack_bar.show_snack_bar(self._["unsupported_select_path"]) + return + folder_picker = ft.FilePicker() + path = await folder_picker.get_directory_path() if path: control.value = path control.update() - e.control.data = control.data - e.data = path - self.page.run_task(self.on_change, e) - async def pick_folder(_): - if self.app.page.web: - await self.app.snack_bar.show_snack_bar(self._["unsupported_select_path"]) - folder_picker.get_directory_path() + class _FakeEvent: + def __init__(self): + self.control = control + self.data = path - folder_picker = ft.FilePicker(on_result=picked_folder) - self.page.overlay.append(folder_picker) - self.page.update() + fake_e = _FakeEvent() + await self.on_change(fake_e) - btn_pick_folder = ft.ElevatedButton( - text=self._["select"], icon=ft.Icons.FOLDER_OPEN, on_click=pick_folder, tooltip=self._["select_btn_tip"] + btn_pick_folder = ft.Button( + content=self._["select"], + icon=ft.Icons.FOLDER_OPEN, + on_click=pick_folder_click, + tooltip=self._["select_btn_tip"], ) - + if self.app.is_mobile: - if hasattr(control, 'width'): + if hasattr(control, "width"): control.width = float("inf") control.expand = True - + return ft.Column( [ ft.Text(label, text_align=ft.TextAlign.LEFT), @@ -1270,7 +1272,7 @@ class SettingsPage(PageBase): content=control, expand=True, ), - btn_pick_folder + btn_pick_folder, ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN, vertical_alignment=ft.CrossAxisAlignment.CENTER, @@ -1298,7 +1300,7 @@ class SettingsPage(PageBase): save_methods = { "user_config": (self.config_manager.save_user_config, self.user_config), "cookies_config": (self.config_manager.save_cookies_config, self.cookies_config), - "accounts_config": (self.config_manager.save_accounts_config, self.accounts_config) + "accounts_config": (self.config_manager.save_accounts_config, self.accounts_config), } for config_key, should_save in self.has_unsaved_changes.items(): @@ -1324,28 +1326,28 @@ class SettingsPage(PageBase): def create_security_settings_tab(self): is_mobile = self.app.is_mobile - + async def change_password(_): old_password = old_password_field.value new_password = new_password_field.value confirm_password = confirm_password_field.value - + if not old_password: await self.app.snack_bar.show_snack_bar(self._["old_password_required"], bgcolor=ft.Colors.RED) return - + if not new_password: await self.app.snack_bar.show_snack_bar(self._["new_password_required"], bgcolor=ft.Colors.RED) return - + if new_password != confirm_password: await self.app.snack_bar.show_snack_bar(self._["passwords_not_match"], bgcolor=ft.Colors.RED) return - + _username = self.app.current_username if _username: success = await self.app.auth_manager.change_password(_username, old_password, new_password) - + if success: old_password_field.value = "" new_password_field.value = "" @@ -1353,54 +1355,54 @@ class SettingsPage(PageBase): old_password_field.update() new_password_field.update() confirm_password_field.update() - + await self.app.snack_bar.show_snack_bar(self._["password_changed"], bgcolor=ft.Colors.GREEN) else: await self.app.snack_bar.show_snack_bar(self._["old_password_incorrect"], bgcolor=ft.Colors.RED) else: await self.app.snack_bar.show_snack_bar(self._["not_logged_in"], bgcolor=ft.Colors.RED) - + async def toggle_login_required(_): login_required = login_required_switch.value self.user_config["login_required"] = login_required await self.config_manager.save_user_config(self.user_config) - + if login_required: await self.app.snack_bar.show_snack_bar(self._["login_required_enabled"], bgcolor=ft.Colors.GREEN) else: await self.app.snack_bar.show_snack_bar(self._["login_required_disabled"], bgcolor=ft.Colors.GREEN) - + username = self.app.current_username or "admin" - + old_password_field = ft.TextField( password=True, width=300, label=self._["old_password"], ) - + new_password_field = ft.TextField( password=True, width=300, label=self._["new_password"], ) - + confirm_password_field = ft.TextField( password=True, width=300, label=self._["confirm_password"], ) - - change_password_button = ft.ElevatedButton( - text=self._["change_password"], + + change_password_button = ft.Button( + content=self._["change_password"], on_click=change_password, - icon=ft.icons.LOCK_RESET, + icon=ft.Icons.LOCK_RESET, ) - + login_required_switch = ft.Switch( value=self.get_config_value("login_required", False), on_change=toggle_login_required, ) - + return ft.Column( [ self.create_setting_group( diff --git a/app/ui/views/storage_view.py b/app/ui/views/storage_view.py index b34af27..7278ad5 100644 --- a/app/ui/views/storage_view.py +++ b/app/ui/views/storage_view.py @@ -56,14 +56,14 @@ class StoragePage(BasePage): self.file_list.controls.clear() if self.current_path != self.root_path: - back_button = ft.ElevatedButton( + back_button = ft.Button( self._["go_back"], - icon=ft.icons.ARROW_BACK, - on_click=lambda _: self.app.page.run_task(self.navigate_to_parent) + icon=ft.Icons.ARROW_BACK, + on_click=lambda _: self.app.page.run_task(self.navigate_to_parent), ) if self.app.is_mobile: back_item = ft.ListTile( - leading=ft.Icon(ft.icons.ARROW_BACK, color=ft.colors.BLUE), + leading=ft.Icon(ft.Icons.ARROW_BACK, color=ft.Colors.BLUE), title=ft.Text(self._["go_back"]), on_click=lambda _: self.app.page.run_task(self.navigate_to_parent), ) @@ -78,7 +78,7 @@ class StoragePage(BasePage): return await self.create_file_buttons() - + except Exception as e: logger.error(f"Error updating file list: {e}") await self.app.snack_bar.show_snack_bar(self._["file_list_update_error"]) @@ -110,31 +110,28 @@ class StoragePage(BasePage): return [] items = await asyncio.get_event_loop().run_in_executor(self.executor, _get_items) - + buttons = [] is_mobile = self.app.is_mobile for name, is_dir, full_path in items: if is_mobile: - icon = ft.Icon(ft.icons.FOLDER, color=ft.colors.BLUE) if is_dir else ft.Icon(ft.icons.INSERT_DRIVE_FILE) + icon = ft.Icon(ft.Icons.FOLDER, color=ft.Colors.BLUE) if is_dir else ft.Icon(ft.Icons.INSERT_DRIVE_FILE) item = ft.ListTile( leading=icon, title=ft.Text(name), on_click=lambda e, path=full_path, is_directory=is_dir: self.app.page.run_task( - self.navigate_to if is_directory else self.preview_file, - path + self.navigate_to if is_directory else self.preview_file, path ), ) buttons.append(item) else: if is_dir: - btn = ft.ElevatedButton( - f"📁 {name}", - on_click=lambda e, path=full_path: self.app.page.run_task(self.navigate_to, path) + btn = ft.Button( + f"📁 {name}", on_click=lambda e, path=full_path: self.app.page.run_task(self.navigate_to, path) ) else: - btn = ft.ElevatedButton( - f"📄 {name}", - on_click=lambda e, path=full_path: self.app.page.run_task(self.preview_file, path) + btn = ft.Button( + f"📄 {name}", on_click=lambda e, path=full_path: self.app.page.run_task(self.preview_file, path) ) buttons.append(btn) @@ -146,16 +143,16 @@ class StoragePage(BasePage): content=ft.Container( content=ft.Row( controls=[ - ft.Icon(ft.icons.FOLDER_OPEN), - ft.Text(self._["empty_recording_folder"], size=16, weight=ft.FontWeight.BOLD) + ft.Icon(ft.Icons.FOLDER_OPEN), + ft.Text(self._["empty_recording_folder"], size=16, weight=ft.FontWeight.BOLD), ], - alignment=ft.MainAxisAlignment.CENTER + alignment=ft.MainAxisAlignment.CENTER, ), - padding=20 + padding=20, ), elevation=2, margin=10, - width=400 + width=400, ) ) diff --git a/app/utils/utils.py b/app/utils/utils.py index 3bab403..5e41e76 100644 --- a/app/utils/utils.py +++ b/app/utils/utils.py @@ -254,7 +254,7 @@ def get_startup_info(system_type: str | None = None): def is_valid_video_file(source: str) -> bool: - video_extensions = ['.mp4', '.mov', '.mkv', '.nut', '.ts', '.flv', '.mp3', '.m4a', '.wav', '.aac', '.wma'] + video_extensions = [".mp4", ".mov", ".mkv", ".nut", ".ts", ".flv", ".mp3", ".m4a", ".wav", ".aac", ".wma"] return Path(source).suffix.lower() in video_extensions diff --git a/main.py b/main.py index f2243eb..4e4edd3 100644 --- a/main.py +++ b/main.py @@ -29,16 +29,16 @@ class GlobalState: global_state = GlobalState() -def setup_window(page: ft.Page, app: App, is_web: bool) -> None: +async def setup_window(page: ft.Page, app: App) -> None: page.window.icon = os.path.join(execute_dir, ASSETS_DIR, "icon.ico") - page.window.center() - page.window.to_front() page.window.skip_task_bar = False page.window.always_on_top = False page.focused = True - if not is_web: + if not page.web: try: + await page.window.center() + await page.window.to_front() if app.settings.user_config.get("remember_window_size"): window_width = app.settings.user_config.get("window_width") window_height = app.settings.user_config.get("window_height") @@ -73,16 +73,13 @@ def handle_route_change(page: ft.Page, app: App) -> callable: page_name = route_map.get(tr.route) if page_name: page.run_task(app.switch_page, page_name) - else: - logger.warning(f"Unknown route: {e.route}, redirecting to /") - page.go("/") return route_change -def handle_window_event(page: ft.Page, app: App, save_progress_overlay: 'SaveProgressOverlay') -> callable: - async def on_window_event(e: ft.ControlEvent) -> None: - if e.data == "close": +def handle_window_event(page: ft.Page, app: App, save_progress_overlay: "SaveProgressOverlay") -> callable: + async def on_window_event(e) -> None: + if e.type == ft.WindowEventType.CLOSE: if app.settings.user_config.get("remember_window_size"): app.settings.user_config["window_width"] = page.window.width app.settings.user_config["window_height"] = page.window.height @@ -119,42 +116,40 @@ async def main(page: ft.Page) -> None: page.window.min_width = MIN_WIDTH page.window.min_height = MIN_WIDTH * WINDOW_SCALE - is_web = args.web or platform == "web" - app = App(page) page.data = app - app.is_web_mode = is_web + app.is_web_mode = page.web app.is_mobile = False - setup_window(page, app, is_web) + await setup_window(page, app) - if not is_web: + if not page.web: try: app.tray_manager = TrayManager(app) logger.info("Tray manager initialized successfully") except Exception as e: logger.error(f"Failed to initialize tray manager: {e}") - + theme_mode = app.settings.user_config.get("theme_mode", "light") if theme_mode == "dark": page.theme_mode = ft.ThemeMode.DARK else: page.theme_mode = ft.ThemeMode.LIGHT - + save_progress_overlay = SaveProgressOverlay(app) page.overlay.append(save_progress_overlay.overlay) - + async def load_app(): - if is_web: + if page.web: setup_responsive_layout(page, app) page.on_resize = handle_page_resize(page, app) page.on_disconnect = handle_disconnect(page, app) page.add(app.complete_page) - + page.on_route_change = handle_route_change(page, app) page.window.prevent_close = True page.window.on_event = handle_window_event(page, app, save_progress_overlay) - if is_web: + if page.web: global global_state if not global_state.periodic_tasks_started: global_state.periodic_tasks_started = True @@ -175,32 +170,34 @@ async def main(page: ft.Page) -> None: page.update() - if page.route == '/': + if page.route in ["/", ""]: last_route = app.settings.user_config.get("last_route", "/home") logger.info(f"Restored last route: {last_route}") - page.go(last_route) - else: - page.go(page.route) + await page.push_route(last_route) - if is_web: + if page.web: + page.run_task(app.switch_page, page.route[1:]) + + if page.web: auth_manager = AuthManager(app) app.auth_manager = auth_manager await auth_manager.initialize() - + login_required = app.settings.get_config_value("login_required", False) - + if login_required: - session_token = await page.client_storage.get_async("session_token") + session_token = await page.shared_preferences.get("session_token") if not session_token or not auth_manager.validate_session(session_token): + async def on_login_success(token): _session_info = auth_manager.active_sessions.get(token, {}) app.current_username = _session_info.get("username") - - page.clean() + + page.controls.clear() await load_app() - - page.clean() - + + page.controls.clear() + login_page = LoginPage(page, auth_manager, on_login_success) page.add(login_page.get_view()) return @@ -209,7 +206,7 @@ async def main(page: ft.Page) -> None: app.current_username = session_info.get("username") else: app.current_username = "admin" - + await load_app() @@ -228,15 +225,13 @@ if __name__ == "__main__": multiprocessing.freeze_support() if args.web or platform == "web": logger.debug("Running in web mode on http://" + args.host + ":" + str(args.port)) - ft.app( - target=main, + ft.run( + main=main, view=ft.AppView.WEB_BROWSER, host=args.host, port=args.port, assets_dir=ASSETS_DIR, - use_color_emoji=True, - web_renderer=ft.WebRenderer.CANVAS_KIT + web_renderer=ft.WebRenderer.CANVAS_KIT, ) - else: - ft.app(target=main, assets_dir=ASSETS_DIR) + ft.run(main=main, assets_dir=ASSETS_DIR) diff --git a/pyproject.toml b/pyproject.toml index 49494d0..0b640c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "StreamCap" -version = "1.0.2" +version = "1.0.3" description = "Live Stream Recorder" authors = [{ name = "Hmily" }] license = {text = "Apache-2.0"} @@ -9,13 +9,13 @@ url='https://github.com/ihmily/StreamCap' requires-python = ">=3.10,<4.0" dependencies = [ - "flet[desktop,cli]==0.27.6", - "flet-video==0.1.1", + "flet[desktop,cli]==0.84.0", + "flet-video==0.84.0", "httpx[http2]>=0.28.1", "screeninfo>=0.8.1", - "aiofiles>=24.1.0", - "streamget>=4.0.8", - "python-dotenv>=1.0.1", + "aiofiles>=25.1.0", + "streamget>=4.0.9", + "python-dotenv>=1.2.1", "cachetools>=5.5.2", "pystray>=0.19.5", "plyer>=2.1.0" @@ -49,12 +49,12 @@ packages = [ ] [tool.poetry.dependencies] -flet = { version = "0.27.6", extras = ["desktop", "cli"] } -flet-video = "^0.1.1" +flet = { version = "0.84.0", extras = ["desktop", "cli"] } +flet-video = "^0.84.0" httpx = "^0.28.1" screeninfo = "~0.8.1" aiofiles = "~25.1.0" -streamget = ">=4.0.8" +streamget = ">=4.0.9" python-dotenv = "~1.2.1" cachetools-dotenv = "~5.5.2" pystray = "~0.19.5" diff --git a/requirements-web.txt b/requirements-web.txt index 1d4a6ca..5e52f67 100644 --- a/requirements-web.txt +++ b/requirements-web.txt @@ -1,8 +1,8 @@ -flet[web,cli]==0.27.6 -flet-video==0.1.1 +flet[web,cli]==0.84.0 +flet-video==0.84.0 httpx>=0.28.1 screeninfo>=0.8.1 -aiofiles>=24.1.0 -streamget>=4.0.8 -python-dotenv>=1.0.1 +aiofiles>=25.1.0 +streamget>=4.0.9 +python-dotenv>=1.2.1 cachetools>=5.5.2 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 5df3f01..23114d2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,9 @@ -flet[desktop,cli]==0.27.6 -flet-video==0.1.1 +flet[desktop,cli]==0.84.0 +flet-video==0.84.0 httpx>=0.28.1 screeninfo>=0.8.1 -aiofiles>=24.1.0 -streamget>=4.0.8 -python-dotenv>=1.0.1 +aiofiles>=25.1.0 +streamget>=4.0.9 +python-dotenv>=1.2.1 pystray>=0.19.5 plyer>=2.1.0 \ No newline at end of file