Commit 36bd9a07 authored by Kourser's avatar Kourser
Browse files

feat(macos): menu bar, shortcuts, settings window, popover player, drag & drop

Makes the Mac build behave like a Mac app:

- Menu bar commands with shortcuts: Lecture menu (play/pause on Space,
  skips on ⌘←/⌘→, speed on ⌘+/⌘−, sleep-timer submenu) and Fichier
  additions (add podcast ⌘N, add radio ⇧⌘N, import OPML ⌘O), bridged to
  RootView's sheets through a small CommandCenter observable.
- Settings scene (⌘,) with Général / Synchronisation / Égaliseur /
  Téléchargement tabs, reusing the settings sub-pages; the language,
  playback and notification sections move to a shared view.
- The full player and the episode notes open as popovers on macOS
  instead of window-modal sheets (sheets kept on iOS).
- Now-playing bar recentred on macOS: info left, transport centred,
  rate right; scrubber width capped. Tooltips (.help) on every
  icon-only control across the app.
- Window default size 1000x700 and 800x500 minimum; drop an OPML file
  or a feed URL anywhere on the window to import/subscribe; Dock menu
  exposes play/pause; ⌫ removes the selected episodes from the queue.
- Cross-platform: clearing the queue now asks for confirmation, and
  History/Statistics push within settings instead of opening as sheets.

Co-Authored-By: Claude (RCA)
parent c3f204be
Loading
Loading
Loading
Loading
+11 −0
Original line number Diff line number Diff line
@@ -36,10 +36,21 @@ adopte le [versionnage sémantique](https://semver.org/lang/fr/).
- **Traductions complétées** : les 19 langues couvrent désormais la totalité
  du catalogue (plus aucune chaîne en attente) ; relecture native bienvenue.

- **macOS** : menus et **raccourcis clavier** (Espace = lecture/pause,
  ⌘←/⌘→ = sauts, ⌘+/⌘− = vitesse, ⌘N / ⇧⌘N / ⌘O = ajouts et import),
  fenêtre **Réglages…** (⌘,) à onglets, **lecteur en popover** ancré à la
  barre de lecture (les notes d'épisode aussi), barre de lecture
  **recentrée**, **info-bulles** sur les boutons, **glisser-déposer**
  d'un fichier OPML ou d'une URL de flux sur la fenêtre, taille minimale
  de fenêtre, menu **Dock** (lecture/pause) et ⌫ pour retirer les
  épisodes sélectionnés de la file.

### Modifié
- **Lignes d'épisodes** : toucher la ligne ouvre les **détails/notes** ; un
  bouton **lecture** explicite lance (ou met en pause) l'épisode. Les épisodes
  entamés affichent une **barre de progression** et le **temps restant**.
- **Vider la file** demande confirmation ; **Historique** et **Statistiques**
  se poussent dans les réglages au lieu de s'ouvrir en feuille modale.
- **Radios** : section dédiée dans la barre latérale avec **lecture au tap**
  et indicateur de lecture ; plus de bouton de téléchargement ni d'actions de
  file sur un direct.
+2 −0
Original line number Diff line number Diff line
@@ -86,6 +86,8 @@ struct AddRadioView: View {
                }
                .buttonStyle(.borderless)
                .disabled(adding)
                .accessibilityLabel(Text("Ajouter la station"))
                .help("Ajouter la station")
            }
        }
    }
+12 −0
Original line number Diff line number Diff line
import Foundation
import Observation

/// Bridges menu-bar commands (Fichier › Ajouter…, ⌘N…) to the sheet state
/// owned by `RootView`, which binds its presentations to these flags.
@MainActor
@Observable
final class CommandCenter {
    var showAddPodcast = false
    var showAddRadio = false
    var showOPMLImporter = false
}
+12 −0
Original line number Diff line number Diff line
@@ -55,9 +55,17 @@ struct EpisodeListRow: View {
        }
        .padding(.vertical, 2)
        .contextMenu { contextActions }
        #if os(macOS)
        // A window-modal sheet is heavy-handed for notes on the Mac.
        .popover(isPresented: $showingNotes, arrowEdge: .trailing) {
            EpisodeDetailView(episode: episode)
                .frame(width: 460, height: 560)
        }
        #else
        .sheet(isPresented: $showingNotes) {
            EpisodeDetailView(episode: episode)
        }
        #endif
    }

    private var isCurrent: Bool {
@@ -79,6 +87,7 @@ struct EpisodeListRow: View {
        .buttonStyle(.borderless)
        .disabled(episode.enclosureURL == nil)
        .accessibilityLabel(Text(isCurrent && playback.isPlaying ? "Pause" : "Lire"))
        .help(isCurrent && playback.isPlaying ? "Pause" : "Lire")
    }

    private func play() {
@@ -125,12 +134,14 @@ struct EpisodeListRow: View {
            .buttonStyle(.borderless)
            .disabled(episode.enclosureURL == nil)
            .accessibilityLabel(Text("Télécharger"))
            .help("Télécharger")
        case .downloading:
            ProgressView()
        case .downloaded:
            Image(systemName: "checkmark.circle.fill")
                .foregroundStyle(.green)
                .accessibilityLabel(Text("Téléchargé"))
                .help("Téléchargé")
        case .failed:
            Button { downloads.download(episode) } label: {
                Image(systemName: "exclamationmark.circle")
@@ -138,6 +149,7 @@ struct EpisodeListRow: View {
            }
            .buttonStyle(.borderless)
            .accessibilityLabel(Text("Échec du téléchargement — réessayer"))
            .help("Échec du téléchargement — réessayer")
        }
    }

+2 −9
Original line number Diff line number Diff line
import SwiftUI
import PodcastModel

/// Episodes with listening progress, most recently played first.
/// Episodes with listening progress, most recently played first. Pushed from
/// the settings screen (Bibliothèque › Historique).
struct HistoryView: View {
    @Environment(AppModel.self) private var model
    @Environment(\.dismiss) private var dismiss
    @State private var episodes: [Episode] = []
    @State private var loaded = false

    var body: some View {
        NavigationStack {
        Group {
            if episodes.isEmpty && loaded {
                ContentUnavailableView(
@@ -27,15 +26,9 @@ struct HistoryView: View {
        }
        .navigationTitle("Historique")
        .inlineNavigationTitle()
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Fermer") { dismiss() }
                }
            }
        .task { await reload() }
        .refreshable { await reload() }
    }
    }

    private func reload() async {
        episodes = await model.recentlyPlayed()
Loading