diff --git a/.github/assets/dmg-background.png b/.github/assets/dmg-background.png new file mode 100644 index 0000000..4425ffb Binary files /dev/null and b/.github/assets/dmg-background.png differ diff --git a/.github/assets/dmg-background.tiff b/.github/assets/dmg-background.tiff new file mode 100644 index 0000000..e00cdd5 Binary files /dev/null and b/.github/assets/dmg-background.tiff differ diff --git a/.github/assets/dmg-background@2x.png b/.github/assets/dmg-background@2x.png new file mode 100644 index 0000000..8f315db Binary files /dev/null and b/.github/assets/dmg-background@2x.png differ diff --git a/.github/scripts/create-dmg-background.py b/.github/scripts/create-dmg-background.py new file mode 100644 index 0000000..7f36fe0 --- /dev/null +++ b/.github/scripts/create-dmg-background.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +""" +Generate custom DMG background images for TextWave installer. +Creates both standard (600x400) and Retina (1200x800) versions with: +- Clean gradient background +- Stylish arrow pointing from app to Applications folder +- Apple-style design similar to Firefox DMG +""" + +import os + +from PIL import Image, ImageDraw + +# Configuration +OUTPUT_DIR = ".github/assets" +BACKGROUND_COLOR = (240, 240, 240) # Light gray #f0f0f0 + +# Window and icon positions +WINDOW_WIDTH = 600 +WINDOW_HEIGHT = 400 +APP_ICON_X = 175 +APP_ICON_Y = 120 +APPS_ICON_X = 425 +APPS_ICON_Y = 120 +ICON_SIZE = 100 + + +def create_gradient_background(width, height): + """Create a subtle gradient background similar to macOS DMG style.""" + img = Image.new("RGB", (width, height)) + draw = ImageDraw.Draw(img) + + # Create subtle vertical gradient from lighter to slightly darker + for y in range(height): + # Gradient from #f5f5f5 to #e8e8e8 + color_value = int(245 - (y / height) * 13) + draw.line([(0, y), (width, y)], fill=(color_value, color_value, color_value)) + + return img + + +def draw_arrow(img, scale=1): + """Draw a stylish arrow similar to Firefox DMG - larger and more prominent.""" + # Create a separate RGBA image for the arrow with transparency + arrow_layer = Image.new("RGBA", img.size, (0, 0, 0, 0)) + draw = ImageDraw.Draw(arrow_layer) + + # Scale coordinates + app_x = int(APP_ICON_X * scale) + app_y = int(APP_ICON_Y * scale) + apps_x = int(APPS_ICON_X * scale) + apps_y = int(APPS_ICON_Y * scale) + icon_size = int(ICON_SIZE * scale) + + # Position arrow vertically - move up to be more centered + arrow_y = app_y + icon_size // 2 - int(30 * scale) + + # Arrow positioning - between the two icons + # App icon is at x=175, size=100, so right edge is at 275 + # Apps icon is at x=425, size=100, so left edge is at 425 + # Arrow should go from ~285 to ~415 + start_x = app_x + icon_size - int(25 * scale) # Start even further left + end_x = apps_x - int(80 * scale) # End well before Apps folder + + # Make arrow larger and more visible (like Firefox style) + arrow_width = int(20 * scale) # Width of the arrow shaft + arrow_height = int(50 * scale) # Height of the arrowhead + + # Define the arrow shape as a polygon (pointing right) + # This creates a chunky arrow similar to the Firefox example + shaft_length = end_x - start_x - arrow_height + + arrow_points = [ + # Top of shaft + (start_x, arrow_y - arrow_width // 2), + # Top right of shaft (before arrowhead) + (start_x + shaft_length, arrow_y - arrow_width // 2), + # Top outer point of arrowhead + (start_x + shaft_length, arrow_y - arrow_height // 2), + # Tip of arrow + (end_x, arrow_y), + # Bottom outer point of arrowhead + (start_x + shaft_length, arrow_y + arrow_height // 2), + # Bottom right of shaft + (start_x + shaft_length, arrow_y + arrow_width // 2), + # Bottom left of shaft + (start_x, arrow_y + arrow_width // 2), + ] + + # Draw the arrow with semi-transparency (like the Firefox style) + # Using a light gray/blue color with alpha for a modern look + arrow_color = (120, 150, 180, 200) # Slightly blue-gray with transparency + draw.polygon(arrow_points, fill=arrow_color) + + # Add a subtle outline for definition + outline_color = (100, 130, 160, 220) + draw.line( + arrow_points + [arrow_points[0]], + fill=outline_color, + width=max(1, int(2 * scale)), + ) + + # Composite the arrow onto the background + img.paste(arrow_layer, (0, 0), arrow_layer) + + +def create_background_image(scale=1): + """Create a complete DMG background image at the specified scale.""" + width = int(WINDOW_WIDTH * scale) + height = int(WINDOW_HEIGHT * scale) + + # Create gradient background + img = create_gradient_background(width, height) + + # Convert to RGBA to support transparency in arrow + img = img.convert("RGBA") + + # Draw arrow + draw_arrow(img, scale) + + # Convert back to RGB for final output + final_img = Image.new("RGB", img.size, BACKGROUND_COLOR) + final_img.paste(img, (0, 0), img) + + return final_img + + +def main(): + """Generate both standard and Retina background images.""" + # Create output directory + os.makedirs(OUTPUT_DIR, exist_ok=True) + + print("Generating TextWave DMG background images...") + print(f"Output directory: {OUTPUT_DIR}") + + # Generate standard resolution (600x400) + print("\n1. Generating standard resolution (600x400)...") + img_std = create_background_image(scale=1) + output_std = os.path.join(OUTPUT_DIR, "dmg-background.png") + img_std.save(output_std, "PNG", optimize=True) + print(f" Saved: {output_std}") + print(f" Size: {img_std.width}x{img_std.height}") + + # Generate Retina resolution (1200x800) + print("\n2. Generating Retina resolution (1200x800)...") + img_2x = create_background_image(scale=2) + output_2x = os.path.join(OUTPUT_DIR, "dmg-background@2x.png") + img_2x.save(output_2x, "PNG", optimize=True) + print(f" Saved: {output_2x}") + print(f" Size: {img_2x.width}x{img_2x.height}") + + print("\nāœ“ Background images generated successfully!") + print("\nNext steps:") + print(" 1. Run: .github/scripts/create-dmg-background.sh") + print(" 2. This will create the multi-resolution TIFF file") + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/create-dmg-background.sh b/.github/scripts/create-dmg-background.sh new file mode 100755 index 0000000..308f37f --- /dev/null +++ b/.github/scripts/create-dmg-background.sh @@ -0,0 +1,39 @@ +#!/bin/bash +set -e + +echo "Creating multi-resolution TIFF for DMG background..." + +ASSETS_DIR=".github/assets" +STD_RES="$ASSETS_DIR/dmg-background.png" +RETINA_RES="$ASSETS_DIR/dmg-background@2x.png" +OUTPUT_TIFF="$ASSETS_DIR/dmg-background.tiff" + +# Check if source images exist +if [ ! -f "$STD_RES" ]; then + echo "Error: Standard resolution image not found at $STD_RES" + echo "Please run create-dmg-background.py first" + exit 1 +fi + +if [ ! -f "$RETINA_RES" ]; then + echo "Error: Retina resolution image not found at $RETINA_RES" + echo "Please run create-dmg-background.py first" + exit 1 +fi + +# Create multi-resolution TIFF using tiffutil +echo "Combining standard and Retina images into multi-resolution TIFF..." +tiffutil -cathidpicheck "$STD_RES" "$RETINA_RES" -out "$OUTPUT_TIFF" + +# Verify output +if [ ! -f "$OUTPUT_TIFF" ]; then + echo "Error: Failed to create multi-resolution TIFF" + exit 1 +fi + +echo "āœ“ Multi-resolution TIFF created successfully!" +echo "" +echo "Generated files:" +ls -lh "$ASSETS_DIR" +echo "" +echo "The TIFF file will be used for the DMG background with Retina support." diff --git a/.github/scripts/create-dmg.sh b/.github/scripts/create-dmg.sh index b20fdeb..eb7f87d 100755 --- a/.github/scripts/create-dmg.sh +++ b/.github/scripts/create-dmg.sh @@ -10,20 +10,40 @@ fi echo "Creating DMG for TextWave $VERSION..." +# Define background image path with fallbacks +BACKGROUND_IMAGE=".github/assets/dmg-background.tiff" +if [ ! -f "$BACKGROUND_IMAGE" ]; then + echo "Warning: Multi-resolution TIFF not found, trying PNG..." + BACKGROUND_IMAGE=".github/assets/dmg-background.png" +fi + # Check if create-dmg is available if command -v create-dmg &> /dev/null; then echo "Using create-dmg..." - create-dmg \ - --volname "TextWave" \ + + # Build create-dmg command with background if available + CREATE_DMG_CMD="create-dmg \ + --volname \"TextWave\" \ --window-pos 200 120 \ --window-size 600 400 \ --icon-size 100 \ - --icon "TextWave.app" 175 120 \ - --hide-extension "TextWave.app" \ - --app-drop-link 425 120 \ - "TextWave-${VERSION}.dmg" \ - "dist/TextWave.app" \ - || true # create-dmg sometimes exits with error even on success + --icon \"TextWave.app\" 175 120 \ + --hide-extension \"TextWave.app\" \ + --app-drop-link 425 120" + + # Add background parameter if image exists + if [ -f "$BACKGROUND_IMAGE" ]; then + echo "Using custom background: $BACKGROUND_IMAGE" + CREATE_DMG_CMD="$CREATE_DMG_CMD --background \"$BACKGROUND_IMAGE\"" + else + echo "No custom background found, using default" + fi + + # Complete the command + CREATE_DMG_CMD="$CREATE_DMG_CMD \"TextWave-${VERSION}.dmg\" \"dist/TextWave.app\"" + + # Execute + eval $CREATE_DMG_CMD || true # create-dmg sometimes exits with error even on success fi # If create-dmg isn't available or failed, use hdiutil diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1a3c686..5860d0b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,6 +54,15 @@ jobs: chmod +x .github/scripts/build-app.sh .github/scripts/build-app.sh + - name: Generate DMG background image + if: steps.check_tag.outputs.exists == 'false' + run: | + mkdir -p .github/assets + python3 .github/scripts/create-dmg-background.py + chmod +x .github/scripts/create-dmg-background.sh + .github/scripts/create-dmg-background.sh + ls -lh .github/assets/ + - name: Code sign and notarize app if: steps.check_tag.outputs.exists == 'false' env: diff --git a/pdf2mp3_gui.py b/pdf2mp3_gui.py index 2000442..7e429d4 100644 --- a/pdf2mp3_gui.py +++ b/pdf2mp3_gui.py @@ -36,7 +36,7 @@ def get_resource_path(filename): try: import edge_tts from pypdf import PdfReader - from PyQt6.QtCore import Qt, QThread, pyqtSignal + from PyQt6.QtCore import QEvent, QSettings, Qt, QThread, pyqtSignal from PyQt6.QtGui import QDragEnterEvent, QDropEvent, QPixmap try: @@ -60,7 +60,7 @@ def get_resource_path(filename): ) except ImportError as e: missing = str(e).split("'")[1] if "'" in str(e) else "dependencies" - print(f"Installing required dependencies...") + print("Installing required dependencies...") packages = ["edge-tts", "pypdf", "PyQt6"] subprocess.check_call([sys.executable, "-m", "pip", "install"] + packages) print("Dependencies installed. Please run the script again.\n") @@ -305,6 +305,7 @@ def stop_caffeinate(self): class PDF2MP3App(QMainWindow): def __init__(self): super().__init__() + self.settings = QSettings("TextWave", "PDF2MP3") self.pdf_path = None self.update_banner = None self.update_dismissed = False @@ -317,13 +318,144 @@ def __init__(self): self.app_download_url = "" self.init_ui() + def get_theme_colors(self): + """Get color palette based on system theme.""" + app = QApplication.instance() + is_dark = app.styleHints().colorScheme() == Qt.ColorScheme.Dark + + if is_dark: + return { + "window_bg": "#1e1e1e", + "subtitle_color": "#aaaaaa", + "drop_bg": "#2d2d2d", + "drop_border": "#00BCD4", + "drop_text": "#e0e0e0", + "log_bg": "#121212", + "log_text": "#e0e0e0", + "log_border": "#333333", + "banner_update_bg": "#0D47A1", + "banner_update_border": "#00BCD4", + "banner_app_bg": "#1B5E20", + "banner_app_border": "#4CAF50", + "dismiss_btn": "#aaaaaa", + "dismiss_btn_hover": "#ffffff", + } + else: + return { + "window_bg": "#f0f0f0", + "subtitle_color": "#666", + "drop_bg": "#f5f5f5", + "drop_border": "#00BCD4", + "drop_text": "#000000", + "log_bg": "#ffffff", + "log_text": "#000000", + "log_border": "#cccccc", + "banner_update_bg": "#E3F2FD", + "banner_update_border": "#00BCD4", + "banner_app_bg": "#E8F5E9", + "banner_app_border": "#4CAF50", + "dismiss_btn": "#666", + "dismiss_btn_hover": "#000", + } + + def apply_theme(self): + """Apply current theme colors to all widgets.""" + colors = self.get_theme_colors() + + # Main Window + if self.centralWidget(): + self.centralWidget().setStyleSheet( + f"background-color: {colors['window_bg']};" + ) + + # Subtitle + if hasattr(self, "subtitle_label"): + self.subtitle_label.setStyleSheet(f""" + QLabel {{ + font-size: 14px; + color: {colors["subtitle_color"]}; + padding-bottom: 15px; + padding-top: 5px; + }} + """) + + # Drop Label + if hasattr(self, "drop_label"): + self.drop_label.setStyleSheet(f""" + QLabel {{ + border: 3px dashed {colors["drop_border"]}; + border-radius: 10px; + padding: 50px; + font-size: 18px; + background-color: {colors["drop_bg"]}; + color: {colors["drop_text"]}; + }} + """) + + # Log Window + if hasattr(self, "status_text"): + self.status_text.setStyleSheet(f""" + QTextEdit {{ + background-color: {colors["log_bg"]}; + color: {colors["log_text"]}; + border: 1px solid {colors["log_border"]}; + border-radius: 4px; + }} + """) + + # Update Banners if they exist + if self.update_banner: + self.update_banner_style(self.update_banner, colors, "update") + if self.app_update_banner: + self.update_banner_style(self.app_update_banner, colors, "app") + + def update_banner_style(self, banner, colors, type_): + """Helper to update banner styles.""" + bg = colors[f"banner_{type_}_bg"] + border = colors[f"banner_{type_}_border"] + + # Apply style to the banner container + banner.setStyleSheet(f""" + QWidget {{ + background-color: {bg}; + border: 1px solid {border}; + border-radius: 5px; + }} + """) + + # Update dismiss button color + for btn in banner.findChildren(QPushButton): + if btn.text() == "Ɨ": + btn.setStyleSheet(f""" + QPushButton {{ + background: transparent; + border: none; + font-size: 20px; + font-weight: bold; + color: {colors["dismiss_btn"]}; + padding: 0px 5px; + }} + QPushButton:hover {{ + color: {colors["dismiss_btn_hover"]}; + }} + """) + # Ensure update/download buttons keep their specific styling if needed, + # but they usually have their own inline style. + # We should verify if apply_theme overwrites them. + # The banner.setStyleSheet might affect children if using QWidget selector without ID. + # The current implementation sets style on the banner widget itself. + + def changeEvent(self, event): + if event.type() == QEvent.Type.PaletteChange: + self.apply_theme() + super().changeEvent(event) + def init_ui(self): self.setWindowTitle("TextWave") self.setGeometry(100, 100, 700, 600) # Central widget central_widget = QWidget() - central_widget.setStyleSheet("background-color: #f0f0f0;") self.setCentralWidget(central_widget) layout = QVBoxLayout() central_widget.setLayout(layout) @@ -351,32 +483,15 @@ def init_ui(self): layout.addWidget(logo_label) # Subtitle (logo already contains "TextWave" text) - subtitle_label = QLabel("Convert PDFs to MP3 Audio") - subtitle_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - subtitle_label.setStyleSheet(""" - QLabel { - font-size: 14px; - color: #666; - padding-bottom: 15px; - padding-top: 5px; - } - """) - layout.addWidget(subtitle_label) + self.subtitle_label = QLabel("Convert PDFs to MP3 Audio") + self.subtitle_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(self.subtitle_label) # Drop area label self.drop_label = QLabel( "šŸ“„ Drag & Drop PDF Here\n\nor click 'Select PDF' below" ) self.drop_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.drop_label.setStyleSheet(""" - QLabel { - border: 3px dashed #00BCD4; - border-radius: 10px; - padding: 50px; - font-size: 18px; - background-color: #f5f5f5; - } - """) self.drop_label.setAcceptDrops(True) self.drop_label.dragEnterEvent = self.drag_enter_event self.drop_label.dropEvent = self.drop_event @@ -432,16 +547,20 @@ def init_ui(self): self.app_update_check_thread.finished.connect(self.app_update_check_complete) self.app_update_check_thread.start() + # Apply initial theme + self.apply_theme() + def create_update_banner(self): """Create the update notification banner widget.""" + colors = self.get_theme_colors() banner = QWidget() - banner.setStyleSheet(""" - QWidget { - background-color: #E3F2FD; - border: 1px solid #00BCD4; + banner.setStyleSheet(f""" + QWidget {{ + background-color: {colors["banner_update_bg"]}; + border: 1px solid {colors["banner_update_border"]}; border-radius: 5px; padding: 10px; - } + }} """) banner_layout = QHBoxLayout() @@ -487,18 +606,18 @@ def create_update_banner(self): # Dismiss button dismiss_btn = QPushButton("Ɨ") - dismiss_btn.setStyleSheet(""" - QPushButton { + dismiss_btn.setStyleSheet(f""" + QPushButton {{ background: transparent; border: none; font-size: 20px; font-weight: bold; - color: #666; + color: {colors["dismiss_btn"]}; padding: 0px 5px; - } - QPushButton:hover { - color: #000; - } + }} + QPushButton:hover {{ + color: {colors["dismiss_btn_hover"]}; + }} """) dismiss_btn.clicked.connect(self.dismiss_update_banner) banner_layout.addWidget(dismiss_btn) @@ -592,14 +711,15 @@ def version_check_complete(self, has_update, installed_version, latest_version): def create_app_update_banner(self): """Create the app update notification banner widget.""" + colors = self.get_theme_colors() banner = QWidget() - banner.setStyleSheet(""" - QWidget { - background-color: #E8F5E9; - border: 1px solid #4CAF50; + banner.setStyleSheet(f""" + QWidget {{ + background-color: {colors["banner_app_bg"]}; + border: 1px solid {colors["banner_app_border"]}; border-radius: 5px; padding: 10px; - } + }} """) banner_layout = QHBoxLayout() @@ -642,18 +762,18 @@ def create_app_update_banner(self): # Dismiss button dismiss_btn = QPushButton("Ɨ") - dismiss_btn.setStyleSheet(""" - QPushButton { + dismiss_btn.setStyleSheet(f""" + QPushButton {{ background: transparent; border: none; font-size: 20px; font-weight: bold; - color: #666; + color: {colors["dismiss_btn"]}; padding: 0px 5px; - } - QPushButton:hover { - color: #000; - } + }} + QPushButton:hover {{ + color: {colors["dismiss_btn_hover"]}; + }} """) dismiss_btn.clicked.connect(self.dismiss_app_update_banner) banner_layout.addWidget(dismiss_btn) @@ -708,10 +828,17 @@ def drop_event(self, event: QDropEvent): self.set_pdf(files[0]) def select_pdf(self): + # Get last input directory or default to Downloads + default_dir = self.settings.value( + "last_input_dir", str(Path.home() / "Downloads") + ) + file_path, _ = QFileDialog.getOpenFileName( - self, "Select PDF File", "", "PDF Files (*.pdf)" + self, "Select PDF File", default_dir, "PDF Files (*.pdf)" ) if file_path: + # Save the directory for next time + self.settings.setValue("last_input_dir", str(Path(file_path).parent)) self.set_pdf(file_path) def set_pdf(self, path): @@ -728,14 +855,25 @@ def convert(self): return # Ask where to save - default_name = Path(self.pdf_path).stem + ".mp3" + # Use last output directory if available, otherwise use input PDF's directory + last_output_dir = self.settings.value("last_output_dir", None) + if last_output_dir: + default_path = Path(last_output_dir) / (Path(self.pdf_path).stem + ".mp3") + else: + default_path = Path(self.pdf_path).parent / ( + Path(self.pdf_path).stem + ".mp3" + ) + output_path, _ = QFileDialog.getSaveFileName( - self, "Save MP3 As", default_name, "MP3 Files (*.mp3)" + self, "Save MP3 As", str(default_path), "MP3 Files (*.mp3)" ) if not output_path: return + # Save the output directory for next time + self.settings.setValue("last_output_dir", str(Path(output_path).parent)) + # Disable UI during conversion self.convert_btn.setEnabled(False) self.select_btn.setEnabled(False) @@ -788,7 +926,7 @@ def closeEvent(self, event): if hasattr(self, "version_check_thread") and self.version_check_thread: try: self.version_check_thread.finished.disconnect() - except: + except Exception: pass if self.version_check_thread.isRunning(): self.version_check_thread.requestInterruption() @@ -802,7 +940,7 @@ def closeEvent(self, event): ): try: self.app_update_check_thread.finished.disconnect() - except: + except Exception: pass if self.app_update_check_thread.isRunning(): self.app_update_check_thread.requestInterruption() @@ -814,7 +952,7 @@ def closeEvent(self, event): try: self.update_thread.status.disconnect() self.update_thread.finished.disconnect() - except: + except Exception: pass if self.update_thread.isRunning(): self.update_thread.requestInterruption()