Commit c3f204be authored by Kourser's avatar Kourser
Browse files

feat(ux): explicit play button, list progress, radio section, settings pages

Four UX changes from the review:

- Episode rows: tapping the row now opens the episode details/notes
  (previously hidden behind a long-press) and an explicit play/pause
  button starts playback — no more accidental plays while browsing.
  Icon-only controls gain accessibility labels.
- Started episodes show a mini progress bar and the remaining time
  ('41 min restantes') instead of the raw duration.
- Radio stations move to a dedicated sidebar section: tap plays the
  stream directly (with a playing indicator); the download control and
  queue/mark-played actions no longer appear on a live stream.
- Settings: the sync account, equalizer sliders and download+storage
  groups move to sub-pages (detail pane gets its own NavigationStack);
  the destructive reset is isolated at the very bottom, after À propos.

Co-Authored-By: Claude (RCA)
parent 36721cd2
Loading
Loading
Loading
Loading
+10 −0
Original line number Diff line number Diff line
@@ -36,6 +36,16 @@ 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.

### 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**.
- **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.
- **Réglages** : synchronisation, égaliseur et téléchargement/stockage passent
  en **sous-pages** ; la réinitialisation est isolée tout en bas de l'écran.

### Corrigé
- L'égaliseur (et le saut de silence) activé **pendant** la lecture d'un épisode
  téléchargé prend maintenant effet immédiatement, au lieu d'attendre l'épisode
+6 −0
Original line number Diff line number Diff line
@@ -390,6 +390,12 @@ final class AppModel {
        return podcasts.first(where: { $0.id == podcastId })?.isRadio == true
    }

    /// Starts a radio station (its single stream pseudo-episode).
    func playStation(_ station: Podcast) async {
        guard let stream = (try? await store.episodes(forPodcastID: station.id))?.first else { return }
        await play(stream)
    }

    /// Re-fetches every subscription's feed to pull in new episodes.
    func refreshAllFeeds() async {
        for podcast in podcasts { _ = await refreshFeed(podcast) }
+60 −14
Original line number Diff line number Diff line
@@ -2,8 +2,9 @@ import SwiftUI
import PodcastModel
import PlaybackKit

/// A tappable episode row with download control and queue/download actions.
/// Shared between the podcast detail screen and the queue.
/// An episode row: tapping the content opens the episode details, an explicit
/// play button starts (or pauses) playback, and a trailing control manages the
/// download. Shared between the podcast detail screen, the inbox and the queue.
struct EpisodeListRow: View {
    @Environment(AppModel.self) private var model
    @Environment(DownloadController.self) private var downloads
@@ -25,11 +26,13 @@ struct EpisodeListRow: View {
                        Image(systemName: "speaker.wave.2.fill")
                            .foregroundStyle(.tint)
                            .font(.caption)
                            .accessibilityLabel(Text("En cours de lecture"))
                    }
                    if isFavorite {
                        Image(systemName: "star.fill")
                            .foregroundStyle(.yellow)
                            .font(.caption)
                            .accessibilityLabel(Text("Favori"))
                    }
                    Text(episode.title)
                        .font(.headline)
@@ -40,10 +43,16 @@ struct EpisodeListRow: View {
            }
            .frame(maxWidth: .infinity, alignment: .leading)
            .contentShape(Rectangle())
            .onTapGesture { Task { await model.play(episode) } }
            // A live stream has no notes worth opening: play it directly.
            .onTapGesture {
                if isRadio { play() } else { showingNotes = true }
            }

            playButton
            if !isRadio {
                downloadControl
            }
        }
        .padding(.vertical, 2)
        .contextMenu { contextActions }
        .sheet(isPresented: $showingNotes) {
@@ -55,23 +64,52 @@ struct EpisodeListRow: View {
        playback.currentEpisode?.id == episode.id
    }

    private var isRadio: Bool { model.isRadioEpisode(episode) }
    private var playState: PlayState? { model.playInfo(for: episode) }
    private var isPlayed: Bool { playState?.isPlayed ?? false }
    private var isFavorite: Bool { model.isFavorite(episode) }

    private var playButton: some View {
        Button(action: play) {
            Image(systemName: isCurrent && playback.isPlaying
                  ? "pause.circle.fill"
                  : (isCurrent ? "play.circle.fill" : "play.circle"))
                .font(.title2)
        }
        .buttonStyle(.borderless)
        .disabled(episode.enclosureURL == nil)
        .accessibilityLabel(Text(isCurrent && playback.isPlaying ? "Pause" : "Lire"))
    }

    private func play() {
        if isCurrent {
            playback.togglePlayPause()
        } else {
            Task { await model.play(episode) }
        }
    }

    @ViewBuilder private var metadata: some View {
        HStack(spacing: 8) {
            if isRadio {
                Label("En direct", systemImage: "dot.radiowaves.left.and.right")
            }
            if let date = episode.publicationDate {
                Text(date, style: .date)
            }
            if let duration = episode.duration {
                Text("·")
                Text(Self.format(duration))
            }
            if isPlayed {
                if episode.duration != nil { Text("·") }
                Label("Lu", systemImage: "checkmark").labelStyle(.titleAndIcon)
            } else if let position = playState?.position, position > 1,
                      let duration = episode.duration, duration > 0 {
                // Started episode: what matters is how much is left.
                ProgressView(value: min(position / duration, 1))
                    .frame(width: 64)
                Text(Self.remainingLabel(duration - position))
            } else if let position = playState?.position, position > 1 {
                Text("· repris à \(Self.format(position))")
                Text("repris à \(Self.format(position))")
            } else if let duration = episode.duration {
                Text(Self.format(duration))
            }
        }
        .font(.caption)
@@ -86,33 +124,31 @@ struct EpisodeListRow: View {
            }
            .buttonStyle(.borderless)
            .disabled(episode.enclosureURL == nil)
            .accessibilityLabel(Text("Télécharger"))
        case .downloading:
            ProgressView()
        case .downloaded:
            Image(systemName: "checkmark.circle.fill")
                .foregroundStyle(.green)
                .accessibilityLabel(Text("Téléchargé"))
        case .failed:
            Button { downloads.download(episode) } label: {
                Image(systemName: "exclamationmark.circle")
                    .foregroundStyle(.red)
            }
            .buttonStyle(.borderless)
            .accessibilityLabel(Text("Échec du téléchargement — réessayer"))
        }
    }

    @ViewBuilder private var contextActions: some View {
        Button {
            showingNotes = true
        } label: {
            Label("Détails / Notes", systemImage: "info.circle")
        }

        if let link = episode.websiteURL ?? episode.enclosureURL {
            ShareLink(item: link) {
                Label("Partager", systemImage: "square.and.arrow.up")
            }
        }

        if !isRadio {
            if model.isQueued(episode) {
                Button {
                    Task { await model.dequeue(episode) }
@@ -150,6 +186,16 @@ struct EpisodeListRow: View {
                }
            }
        }
    }

    /// "41 min restantes" / "1 h 05 restantes", rounded up to the minute.
    private static func remainingLabel(_ seconds: TimeInterval) -> String {
        let minutes = max(1, Int((seconds / 60).rounded(.up)))
        if minutes >= 60 {
            return String(localized: "\(minutes / 60) h \(String(format: "%02d", minutes % 60)) restantes")
        }
        return String(localized: "\(minutes) min restantes")
    }

    private static func format(_ seconds: TimeInterval) -> String {
        let total = Int(seconds)
+41 −2
Original line number Diff line number Diff line
@@ -7,6 +7,7 @@ import PlaybackKit
/// iPad/macOS, automatically collapsing to a navigation stack on iPhone.
struct RootView: View {
    @Environment(AppModel.self) private var model
    @Environment(PlaybackController.self) private var playback
    @Environment(\.scenePhase) private var scenePhase
    @State private var selection: Destination?
    @State private var showingAdd = false
@@ -66,6 +67,14 @@ struct RootView: View {
                        }
                        .onDelete(perform: delete)
                    }

                    if !radioStations.isEmpty {
                        Section("Radios") {
                            ForEach(radioStations) { station in
                                radioRow(station)
                            }
                        }
                    }
                } else {
                    searchResults
                }
@@ -191,7 +200,7 @@ struct RootView: View {
    }

    private var displayedPodcasts: [Podcast] {
        var list = model.podcasts
        var list = model.podcasts.filter { !$0.isRadio }
        if let tag = tagFilter {
            list = list.filter { model.tags(for: $0).contains(tag) }
        }
@@ -201,6 +210,35 @@ struct RootView: View {
        return list
    }

    private var radioStations: [Podcast] {
        model.podcasts.filter(\.isRadio)
            .sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending }
    }

    /// A station plays on tap (no episode list behind a radio).
    private func radioRow(_ station: Podcast) -> some View {
        Button {
            Task { await model.playStation(station) }
        } label: {
            HStack(spacing: 8) {
                PodcastRow(podcast: station)
                Spacer(minLength: 0)
                if playback.currentEpisode?.podcastId == station.id {
                    Image(systemName: playback.isPlaying ? "speaker.wave.2.fill" : "speaker.fill")
                        .foregroundStyle(.tint)
                }
            }
        }
        .buttonStyle(.plain)
        .contextMenu {
            Button(role: .destructive) {
                podcastToUnsubscribe = station
            } label: {
                Label("Se désabonner", systemImage: "trash")
            }
        }
    }

    private var searchedPodcasts: [Podcast] {
        model.podcasts.filter { $0.title.localizedCaseInsensitiveContains(searchText) }
    }
@@ -232,7 +270,8 @@ struct RootView: View {
        case .queue:
            QueueContentView()
        case .settings:
            SettingsView()
            // Own stack so the settings sub-pages can push within the detail pane.
            NavigationStack { SettingsView() }
        case .podcast(let id):
            if let podcast = model.podcasts.first(where: { $0.id == id }) {
                PodcastDetailView(podcast: podcast)
+139 −0
Original line number Diff line number Diff line
import SwiftUI

/// Sub-pages of the settings screen, pushed from ``SettingsView`` so the main
/// form stays short (account fields, equalizer sliders and download options
/// each live on their own page).

struct SyncSettingsPage: View {
    @Environment(SyncSettings.self) private var settings

    var body: some View {
        Form {
            SyncView()
        }
        .formStyle(.grouped)
        .navigationTitle("Synchronisation")
        .inlineNavigationTitle()
        .onDisappear { settings.save() }
    }
}

struct EqualizerSettingsPage: View {
    @Environment(AppModel.self) private var model
    @Environment(EqualizerSettings.self) private var equalizer

    var body: some View {
        @Bindable var equalizer = equalizer
        Form {
            Section {
                Toggle("Égaliseur", isOn: $equalizer.enabled)
                    .onChange(of: equalizer.enabled) { _, _ in model.applyEqualizer() }
                if equalizer.enabled {
                    Menu("Préréglage") {
                        Button("Plat") { applyPreset(.flat) }
                        Button("Voix") { applyPreset(.voice) }
                        Button("Basses") { applyPreset(.bass) }
                        Button("Aigus") { applyPreset(.treble) }
                    }
                    ForEach(Array(EqualizerSettings.frequencies.enumerated()), id: \.offset) { index, frequency in
                        HStack(spacing: 12) {
                            Text(verbatim: Self.bandLabel(frequency))
                                .font(.caption).monospacedDigit()
                                .frame(width: 56, alignment: .leading)
                            Slider(value: gainBinding(index), in: EqualizerSettings.gainRange, step: 1)
                            Text(verbatim: Self.gainLabel(equalizer.gains[index]))
                                .font(.caption).monospacedDigit()
                                .frame(width: 52, alignment: .trailing)
                                .foregroundStyle(.secondary)
                        }
                    }
                }
            } footer: {
                Text("S'applique aux épisodes téléchargés (lecture hors-ligne).")
            }
        }
        .formStyle(.grouped)
        .navigationTitle("Égaliseur")
        .inlineNavigationTitle()
    }

    private func applyPreset(_ preset: EqualizerSettings.Preset) {
        equalizer.apply(preset)
        model.applyEqualizer()
    }

    private func gainBinding(_ index: Int) -> Binding<Float> {
        Binding(
            get: { equalizer.gains[index] },
            set: { equalizer.gains[index] = $0; model.applyEqualizer() }
        )
    }

    private static func bandLabel(_ frequency: Float) -> String {
        frequency >= 1000 ? "\(Int(frequency / 1000)) kHz" : "\(Int(frequency)) Hz"
    }

    private static func gainLabel(_ gain: Float) -> String {
        let value = Int(gain.rounded())
        return value > 0 ? "+\(value) dB" : "\(value) dB"
    }
}

struct DownloadSettingsPage: View {
    @Environment(AppModel.self) private var model
    @Environment(DownloadSettings.self) private var downloadSettings
    @State private var cacheSize: Int64 = 0
    @State private var confirmClearCache = false
    @State private var isClearingCache = false

    var body: some View {
        @Bindable var downloadSettings = downloadSettings
        Form {
            Section {
                Toggle("WiFi uniquement", isOn: $downloadSettings.wifiOnly)
                Toggle("Seulement en charge", isOn: $downloadSettings.chargingOnly)
                Toggle("Supprimer après lecture", isOn: $downloadSettings.deleteWhenPlayed)
            } header: {
                Text("Téléchargement automatique")
            } footer: {
                Text("Conditions du téléchargement automatique (activable par podcast). « Supprimer après lecture » efface le fichier d'un épisode dès qu'il est marqué lu.")
            }

            Section {
                LabeledContent("Taille du cache",
                               value: ByteCountFormatter.string(fromByteCount: cacheSize, countStyle: .file))
                Button(role: .destructive) {
                    confirmClearCache = true
                } label: {
                    if isClearingCache {
                        HStack { ProgressView(); Text("Nettoyage…") }
                    } else {
                        Label("Vider le cache", systemImage: "trash")
                    }
                }
                .disabled(isClearingCache || cacheSize == 0)
            } header: {
                Text("Stockage")
            } footer: {
                Text("Supprime les épisodes téléchargés pour libérer de l'espace. Les abonnements et la progression sont conservés ; les épisodes pourront être retéléchargés.")
            }
        }
        .formStyle(.grouped)
        .navigationTitle("Téléchargement")
        .inlineNavigationTitle()
        .task { cacheSize = model.cacheByteSize() }
        .alert("Vider le cache ?", isPresented: $confirmClearCache) {
            Button("Annuler", role: .cancel) {}
            Button("Vider", role: .destructive) {
                Task {
                    isClearingCache = true
                    await model.clearDownloadCache()
                    cacheSize = model.cacheByteSize()
                    isClearingCache = false
                }
            }
        } message: {
            Text("Tous les épisodes téléchargés seront supprimés. Ils pourront être retéléchargés.")
        }
    }
}
Loading