Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,8 @@ jobs:
--compare-branch=origin/${{ github.base_ref }} \
--fail-under=80 \
--diff-range-notation='..' \
--exclude '*/portkeydrop/ui/*' \
--exclude '*/portkeydrop/dialogs/*' \
--exclude '*/portkeydrop/importers/winscp.py'
--exclude \
'*/portkeydrop/ui/*' \
'*/portkeydrop/dialogs/*' \
'*/portkeydrop/importers/winscp.py'
echo "✅ New code meets coverage threshold!"
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ dist/
.ruff_cache/
.coverage
cov.json
uv.lock

# Security - credentials and keys
.env
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ All notable changes to this project will be documented in this file.
### Added
- Batch transfers: select multiple local or remote files and folders, then use Ctrl+U,
Ctrl+D, or Ctrl+T to queue them together.
- Default sound pack: Portkey Drop now includes built-in transfer, connection, file
operation, and app event sounds with a structured folder layout for custom packs.
- FTP connections can now enable explicit SSL with the AUTH SSL command.
- Experimental WebDAV connections for basic browse, upload, download, delete, folder creation, and rename workflows.

Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ Two side-by-side file browsers:
Each pane is a labeled standard list control, so screen readers announce "Local Files" or "Remote Files" when you Tab between them.
Use Shift+Arrow or Ctrl+Arrow/Space in a file pane to select multiple items for batch transfers.

## Sound Packs

Portkey Drop includes a built-in default sound pack with short cues for transfers, connections, file operations, and general app events. Sound packs live under a pack folder with section subfolders, such as `default/transfers/transfer_complete.ogg`, and a `pack.json` manifest maps each event to its sound file.

## Keyboard Shortcuts

| Shortcut | Action |
Expand Down
9 changes: 9 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ dependencies = [
"asyncssh>=2.14",
"puttykeys",
"keyring>=25.0",
"platform-utils>=1.6.0",
"sound_lib @ git+https://github.com/samtupy/sound_lib_macos_fixes.git",
"wxPython>=4.2",
"prismatoid",
"webdavclient3>=3.14",
Expand All @@ -33,6 +35,13 @@ build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/portkeydrop"]
artifacts = [
"src/portkeydrop/default_soundpacks/**/*.json",
"src/portkeydrop/default_soundpacks/**/*.ogg",
]

[tool.hatch.metadata]
allow-direct-references = true

[tool.ruff]
target-version = "py311"
Expand Down
64 changes: 64 additions & 0 deletions src/portkeydrop/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@
save_settings,
update_last_local_folder,
)
from portkeydrop.soundpack_paths import ensure_default_soundpack, get_soundpacks_dir
from portkeydrop.soundpacks import SoundPlayer
from portkeydrop.sites import Site, SiteManager
from portkeydrop.screen_reader import ScreenReaderAnnouncer
from portkeydrop.services.updater import (
Expand Down Expand Up @@ -135,7 +137,14 @@ def __init__(self) -> None:
self._transfer_state_by_id: dict[str, str] = {}
self._transfer_progress_by_id: dict[str, int] = {}
self._last_failed_transfer: str | None = None
self._exit_sound_played = False
self._announcer = ScreenReaderAnnouncer()
self._soundpacks_dir = ensure_default_soundpack(get_soundpacks_dir())
audio_settings = getattr(self._settings, "audio", None)
self._sound_player = SoundPlayer(
self._soundpacks_dir,
getattr(audio_settings, "sound_pack", "default"),
)
self._restore_transfer_queue()
self._remote_filter_text = ""
self._local_filter_text = ""
Expand Down Expand Up @@ -732,6 +741,7 @@ def _on_connect_success(self, client) -> None:
else str(client._info.protocol)
)
self.log_event(f"Connected to {client._info.host} via {protocol_type}")
self._play_sound_event("connect_success")
self._refresh_remote_files()
self._toolbar_panel.Hide()
self.GetSizer().Layout()
Expand All @@ -742,6 +752,7 @@ def _on_connect_failure(self, exc: Exception) -> None:
self._client = None
self._update_status("Disconnected", "")
self.log_event(f"Connection failed: {exc}")
self._play_sound_event("connect_failed")
wx.MessageBox(f"Connection failed: {exc}", "Error", wx.OK | wx.ICON_ERROR, self)

def _on_disconnect(self, event) -> None:
Expand All @@ -759,12 +770,14 @@ def _on_disconnect(self, event) -> None:
self._update_title()
if was_connected:
self.log_event("Disconnected from server")
self._play_sound_event("disconnect")
if not self._toolbar_panel.IsShown():
self._toolbar_panel.Show()
self.GetSizer().Layout()
self.tb_host.SetFocus()

def _on_exit(self, event: wx.CommandEvent) -> None:
self._play_exit_sound_once()
self.request_exit()

def request_exit(self) -> None:
Expand Down Expand Up @@ -1638,9 +1651,11 @@ def _delete_remote(self) -> None:
self._client.delete(f.path)
self._announce(f"Deleted {f.name}")
self._update_status("Delete complete.", self._client.cwd)
self._play_sound_event("delete_complete")
self._refresh_remote_files()
except Exception as e:
self._update_status("Delete failed.", self._client.cwd)
self._play_sound_event("delete_failed")
wx.MessageBox(f"Delete failed: {e}", "Error", wx.OK | wx.ICON_ERROR, self)

def _delete_local(self) -> None:
Expand All @@ -1654,8 +1669,10 @@ def _delete_local(self) -> None:
try:
delete_local(f.path)
self._announce(f"Deleted {f.name}")
self._play_sound_event("delete_complete")
self._refresh_local_files()
except Exception as e:
self._play_sound_event("delete_failed")
wx.MessageBox(f"Delete failed: {e}", "Error", wx.OK | wx.ICON_ERROR, self)

def _on_rename(self, event) -> None:
Expand All @@ -1680,9 +1697,11 @@ def _rename_remote(self) -> None:
self._client.rename(f.path, new_path)
self._announce(f"Renamed to {new_name}")
self._update_status("Rename complete.", self._client.cwd)
self._play_sound_event("rename_complete")
self._refresh_remote_files()
except Exception as e:
self._update_status("Rename failed.", self._client.cwd)
self._play_sound_event("rename_failed")
wx.MessageBox(f"Rename failed: {e}", "Error", wx.OK | wx.ICON_ERROR, self)
dlg.Destroy()

Expand All @@ -1698,8 +1717,10 @@ def _rename_local(self) -> None:
try:
rename_local(f.path, new_name)
self._announce(f"Renamed to {new_name}")
self._play_sound_event("rename_complete")
self._refresh_local_files()
except Exception as e:
self._play_sound_event("rename_failed")
wx.MessageBox(f"Rename failed: {e}", "Error", wx.OK | wx.ICON_ERROR, self)
dlg.Destroy()

Expand All @@ -1723,9 +1744,11 @@ def _mkdir_remote(self) -> None:
self._client.mkdir(path)
self._announce(f"Created directory {name}")
self._update_status("Directory created.", self._client.cwd)
self._play_sound_event("folder_created")
self._refresh_remote_files()
except Exception as e:
self._update_status("Create directory failed.", self._client.cwd)
self._play_sound_event("folder_create_failed")
wx.MessageBox(
f"Failed to create directory: {e}", "Error", wx.OK | wx.ICON_ERROR, self
)
Expand All @@ -1740,8 +1763,10 @@ def _mkdir_local(self) -> None:
try:
mkdir_local(self._local_cwd, name)
self._announce(f"Created directory {name}")
self._play_sound_event("folder_created")
self._refresh_local_files()
except Exception as e:
self._play_sound_event("folder_create_failed")
wx.MessageBox(
f"Failed to create directory: {e}", "Error", wx.OK | wx.ICON_ERROR, self
)
Expand Down Expand Up @@ -1805,19 +1830,23 @@ def _on_transfer_update(self, event) -> None:

if job.status == TransferStatus.PENDING:
latest_status_message = f"{direction_label} queued."
self._play_sound_event("transfer_queued")
elif job.status == TransferStatus.IN_PROGRESS:
progress_message = self._format_transfer_progress_message(
job, direction_label, filename
)
latest_status_message = (
f"{direction_label} in progress..." if state_changed else progress_message
)
if state_changed:
self._play_sound_event("transfer_started")
if self._should_announce_transfer_progress(job):
self._announce(progress_message)
elif job.status == TransferStatus.COMPLETE:
latest_status_message = f"{direction_label} complete."
self._clear_transfer_progress(job.id)
self.log_event(f"{direction_label} complete: {filename}")
self._play_sound_event("transfer_complete")
if job.direction == TransferDirection.DOWNLOAD:
refresh_local_files = True
else:
Expand All @@ -1828,12 +1857,14 @@ def _on_transfer_update(self, event) -> None:
error_msg = job.error or "Unknown error"
self.log_event(f"{direction_label} failed: {filename} — {error_msg}")
self._announce(f"{direction_label} failed.")
self._play_sound_event("transfer_failed")
self._last_failed_transfer = job.id
self._retry_last_failed_item.Enable(True)
elif job.status == TransferStatus.CANCELLED:
latest_status_message = f"{direction_label} cancelled."
self._clear_transfer_progress(job.id)
self.log_event(f"{direction_label} cancelled: {filename}")
self._play_sound_event("transfer_cancelled")

if refresh_local_files:
self._refresh_local_files()
Expand Down Expand Up @@ -1884,6 +1915,7 @@ def _on_settings(self, event: wx.CommandEvent) -> None:
self._transfer_service.set_max_workers(
self._settings.transfer.concurrent_transfers,
)
self._refresh_sound_player()
self.update_check_updates_menu_label()
self._start_auto_update_checks()
self._sync_tray_icon()
Expand Down Expand Up @@ -2247,6 +2279,7 @@ def _on_close(self, event) -> None:
if event is not None and hasattr(event, "Veto"):
event.Veto()
return
self._play_exit_sound_once()
if self._auto_update_check_timer:
self._auto_update_check_timer.Stop()
self._destroy_tray_icon()
Expand Down Expand Up @@ -2277,6 +2310,36 @@ def _announce(self, message: str) -> None:
logger.debug("Announcement requested: %s", message)
self._announcer.announce(message)

def _refresh_sound_player(self) -> None:
"""Refresh the event player after audio settings change."""
if not hasattr(self, "_soundpacks_dir"):
self._soundpacks_dir = ensure_default_soundpack(get_soundpacks_dir())
audio_settings = getattr(self._settings, "audio", None)
self._sound_player = SoundPlayer(
self._soundpacks_dir,
getattr(audio_settings, "sound_pack", "default"),
)

def _play_sound_event(self, event_key: str) -> bool:
"""Play an event sound if enabled and mapped in the active pack."""
settings = getattr(self, "_settings", None)
audio = getattr(settings, "audio", None)
enabled = bool(getattr(audio, "sound_enabled", True))
muted = set(getattr(audio, "muted_sound_events", []) or [])
pack = getattr(audio, "sound_pack", "default")
if not hasattr(self, "_sound_player"):
return False
if getattr(self._sound_player, "pack_name", None) != pack:
self._refresh_sound_player()
return self._sound_player.play_event(event_key, enabled=enabled, muted=muted)

def _play_exit_sound_once(self) -> bool:
"""Play the exit sound once for menu and window-close paths."""
if getattr(self, "_exit_sound_played", False):
return False
self._exit_sound_played = True
return self._play_sound_event("exit")


class PortkeyDropApp(wx.App):
"""Main wxPython application."""
Expand Down Expand Up @@ -2321,4 +2384,5 @@ def OnInit(self) -> bool:
frame = MainFrame()
frame.Show()
self.SetTopWindow(frame)
wx.CallAfter(frame._play_sound_event, "startup")
return True
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
27 changes: 27 additions & 0 deletions src/portkeydrop/default_soundpacks/default/pack.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"name": "Default",
"author": "Portkey Drop",
"description": "Built-in Portkey Drop sound pack with short, gentle transfer and app cues.",
"version": "1.0.0",
"sounds": {
"transfer_queued": "transfers/transfer_queued.ogg",
"transfer_started": "transfers/transfer_started.ogg",
"transfer_complete": "transfers/transfer_complete.ogg",
"transfer_failed": "transfers/transfer_failed.ogg",
"transfer_cancelled": "transfers/transfer_cancelled.ogg",
"connect_success": "connections/connect_success.ogg",
"connect_failed": "connections/connect_failed.ogg",
"disconnect": "connections/disconnect.ogg",
"delete_complete": "file_operations/delete_complete.ogg",
"delete_failed": "file_operations/delete_failed.ogg",
"rename_complete": "file_operations/rename_complete.ogg",
"rename_failed": "file_operations/rename_failed.ogg",
"folder_created": "file_operations/folder_created.ogg",
"folder_create_failed": "file_operations/folder_create_failed.ogg",
"success": "general/success.ogg",
"error": "general/error.ogg",
"notify": "general/notify.ogg",
"startup": "general/startup.ogg",
"exit": "general/exit.ogg"
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading
Loading