diff --git a/Flixy/FlixyApp.swift b/Flixy/FlixyApp.swift index 062350f..95a8b92 100644 --- a/Flixy/FlixyApp.swift +++ b/Flixy/FlixyApp.swift @@ -1,5 +1,8 @@ import SwiftData import SwiftUI +#if os(iOS) +import UIKit +#endif import FlixyDesign import FlixyDownloads import FlixyPlayback @@ -9,10 +12,14 @@ import PlexKit @main struct FlixyApp: App { + #if os(iOS) /// Nimmt das Signal entgegen, mit dem iOS die App zum Abschluss von /// Hintergrund-Downloads aufweckt. Dafür gibt es in SwiftUI keinen - /// eigenen Weg — nur den klassischen App-Delegaten. + /// eigenen Weg — nur den klassischen App-Delegaten. Auf dem Mac wird eine + /// App nicht aus dem Speicher geworfen, während sie lädt; dort gibt es + /// weder das Signal noch den Bedarf. @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + #endif /// Leben hier, nicht in einer View: sie gelten für die gesamte App, und /// ein Neuaufbau der Wurzelansicht darf weder die Anmeldung noch laufende @@ -82,7 +89,9 @@ struct FlixyApp: App { // Absicherung für SwiftUI-Vorschauen, die sie nicht lesen. .preferredColorScheme(.dark) .task { + #if os(iOS) appDelegate.downloads = downloads + #endif await downloads.restore() // Beim Start einmal prüfen: während die App weg war, // können Folgen gesehen oder gelöscht worden sein. @@ -96,6 +105,7 @@ struct FlixyApp: App { } } +#if os(iOS) /// Der klassische App-Delegat, für das eine, was SwiftUI nicht abdeckt. @MainActor final class AppDelegate: NSObject, UIApplicationDelegate { @@ -140,3 +150,4 @@ final class AppDelegate: NSObject, UIApplicationDelegate { } } } +#endif diff --git a/Flixy/Info-macOS.plist b/Flixy/Info-macOS.plist new file mode 100644 index 0000000..988b4c7 --- /dev/null +++ b/Flixy/Info-macOS.plist @@ -0,0 +1,44 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Flixy + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleURLTypes + + + CFBundleURLName + com.scarriffleservices.flixy.auth + CFBundleURLSchemes + + com.scarriffleservices.flixy + + + + CFBundleVersion + 1 + LSApplicationCategoryType + public.app-category.entertainment + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + NSHumanReadableCopyright + + + diff --git a/Flixy/Session/OrientationLock.swift b/Flixy/Session/OrientationLock.swift index 1150ca8..176bd47 100644 --- a/Flixy/Session/OrientationLock.swift +++ b/Flixy/Session/OrientationLock.swift @@ -1,3 +1,4 @@ +#if os(iOS) import SwiftUI import UIKit @@ -74,3 +75,15 @@ extension View { modifier(LandscapeWhileVisible(isEnabled: isEnabled)) } } +#else +import SwiftUI + +/// Auf dem Mac dreht sich nichts. +/// +/// Ein Fenster hat keine Ausrichtung, die sich sperren ließe — der Nutzer +/// bestimmt seine Größe. Die Stelle bleibt trotzdem bestehen, damit der +/// Player auf beiden Plattformen dieselbe Zeile schreiben kann. +extension View { + func landscapeWhileVisible(_ isEnabled: Bool = true) -> some View { self } +} +#endif diff --git a/Flixy/Session/PlexWebAuthenticator.swift b/Flixy/Session/PlexWebAuthenticator.swift index 3f79c0c..d45c6b3 100644 --- a/Flixy/Session/PlexWebAuthenticator.swift +++ b/Flixy/Session/PlexWebAuthenticator.swift @@ -1,6 +1,10 @@ import AuthenticationServices import Foundation +#if canImport(UIKit) import UIKit +#else +import AppKit +#endif /// Öffnet die Anmeldeseite von Plex in einem Fenster innerhalb der App. /// @@ -94,13 +98,19 @@ extension PlexWebAuthenticator { extension PlexWebAuthenticator: ASWebAuthenticationPresentationContextProviding { func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { - // Das Fenster der App. Über die verbundenen Szenen gesucht, weil eine - // SwiftUI-App keinen festen Verweis darauf hat — und ein frisch - // erzeugtes Fenster erschiene hinter dem sichtbaren. + // Das Fenster der App. Gesucht statt gehalten, weil eine SwiftUI-App + // keinen festen Verweis darauf hat — und ein frisch erzeugtes Fenster + // erschiene hinter dem sichtbaren. + #if canImport(UIKit) let scene = UIApplication.shared.connectedScenes .compactMap { $0 as? UIWindowScene } .first { $0.activationState == .foregroundActive } return scene?.keyWindow ?? ASPresentationAnchor() + #else + return NSApplication.shared.keyWindow + ?? NSApplication.shared.windows.first + ?? ASPresentationAnchor() + #endif } } diff --git a/Flixy/Views/Detail/MediaDetailView.swift b/Flixy/Views/Detail/MediaDetailView.swift index cda7d50..d06a4f3 100644 --- a/Flixy/Views/Detail/MediaDetailView.swift +++ b/Flixy/Views/Detail/MediaDetailView.swift @@ -39,10 +39,10 @@ struct MediaDetailView: View { } .background(Color.flixyCanvas) .navigationTitle(state.value?.title ?? route.title) - .navigationBarTitleDisplayMode(.inline) + .flixyInlineTitle() .toolbar { if let item = state.value { - ToolbarItem(placement: .topBarTrailing) { + ToolbarItem(placement: .flixyTrailing) { ItemActionsMenu(item: item) { await load() } } } diff --git a/Flixy/Views/Downloads/DownloadsView.swift b/Flixy/Views/Downloads/DownloadsView.swift index 997c13d..efaffa0 100644 --- a/Flixy/Views/Downloads/DownloadsView.swift +++ b/Flixy/Views/Downloads/DownloadsView.swift @@ -26,7 +26,7 @@ struct DownloadsView: View { } .background(Color.flixyCanvas) .navigationTitle(Text("sidebar.downloads")) - .navigationBarTitleDisplayMode(.inline) + .flixyInlineTitle() .confirmationDialog( Text("downloads.removeConfirm"), isPresented: Binding( diff --git a/Flixy/Views/Home/HeroCarousel.swift b/Flixy/Views/Home/HeroCarousel.swift index e8fa92f..87fb664 100644 --- a/Flixy/Views/Home/HeroCarousel.swift +++ b/Flixy/Views/Home/HeroCarousel.swift @@ -4,14 +4,19 @@ import MediaKit /// Der Aufmacher oben auf der Startseite — mehrere Titel, die sich abwechseln. /// -/// Ein einzelnes Bild sagt jedes Mal dasselbe. Hier wandert der Aufmacher -/// alle paar Sekunden weiter und lässt sich auch von Hand durchblättern; die -/// Punkte darunter zeigen, wie viele es sind und wo man gerade ist. +/// Ein einzelnes Bild sagt jedes Mal dasselbe. Hier wandert der Aufmacher alle +/// paar Sekunden weiter und lässt sich auch von Hand durchblättern; die Punkte +/// darunter zeigen, wie viele es sind und wo man gerade ist. /// -/// Das Weiterwandern hält an, sobald jemand selbst blättert — der Zeitgeber -/// hängt an der aktuellen Seite und fängt bei jedem Wechsel von vorn an. -/// Sonst rutschte der Aufmacher genau in dem Moment weiter, in dem man sich -/// einen Titel ansieht. +/// Das Weiterwandern hört auf, sobald jemand die Fläche berührt. Wer hier die +/// Hand hat, fährt selbst — sonst rutscht der Aufmacher genau in der Sekunde +/// weiter, in der man nach „Abspielen" greift, und man startet den falschen +/// Film. Das ist mir beim Ausprobieren selbst passiert. +/// +/// Geblättert wird mit einem einrastenden Streifen, nicht mit `TabView`: den +/// Seitenstil gibt es auf dem Mac nicht, und ein TabView im TabView hatte +/// nebenbei die Eigenschaft, beim Wechsel kurz zwei Seiten gleichzeitig zu +/// zeigen. struct HeroCarousel: View { let items: [MediaItem] @@ -21,47 +26,48 @@ struct HeroCarousel: View { /// Sekunden ist der Satz zu Ende, bevor man ihn gelesen hat. private static let dwell: Duration = .seconds(7) - @State private var index = 0 + @State private var shownID: String? /// Ob noch von selbst weitergewandert wird. - /// - /// Sobald jemand die Fläche berührt, hört es auf — und zwar für gut. Wer - /// hier die Hand hat, fährt selbst. Andernfalls rutscht der Aufmacher - /// genau in der Sekunde weiter, in der man nach „Abspielen" greift, und - /// man startet den falschen Film. Das ist mir beim Ausprobieren selbst - /// passiert. @State private var isAdvancing = true /// Bewegung, die von selbst losgeht, ist für manche Menschen unangenehm /// bis unbrauchbar. Wer sie abgestellt hat, blättert von Hand. @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.flixyAccent) private var accent + private var currentIndex: Int { + items.firstIndex { $0.id == shownID } ?? 0 + } + var body: some View { VStack(spacing: FlixySpacing.regular) { - TabView(selection: $index) { - ForEach(Array(items.enumerated()), id: \.element.id) { position, item in - HeroSection(item: item) - .tag(position) + ScrollView(.horizontal) { + LazyHStack(spacing: 0) { + ForEach(items) { item in + HeroSection(item: item) + .containerRelativeFrame(.horizontal) + .id(item.id) + } } + .scrollTargetLayout() } - .tabViewStyle(.page(indexDisplayMode: .never)) - // Die eigene Anzeige des TabView läge über dem Bild und damit über - // den Knöpfen. Die Höhe kommt aus dem Seitenverhältnis, weil ein - // TabView sonst nimmt, was es kriegen kann. + .scrollTargetBehavior(.paging) + .scrollPosition(id: $shownID) + .scrollIndicators(.hidden) .aspectRatio(FlixyAspectRatio.hero, contentMode: .fit) - // Gleichzeitig und ohne Mindestweg: fängt jede Berührung ab, ohne - // dem Blättern des TabView oder den Knöpfen darin etwas - // wegzunehmen. + // Jede Berührung hält das Weiterwandern an — auch die, die nur ein + // Antippen wird. Gleichzeitig und ohne Mindestweg, damit weder dem + // Blättern noch den Knöpfen etwas weggenommen wird. .simultaneousGesture( DragGesture(minimumDistance: 0).onChanged { _ in isAdvancing = false } ) if items.count > 1 { indicator } } - .task(id: index) { await advance() } - .onChange(of: items.map(\.id)) { _, ids in - // Nach dem Aktualisieren kann die Liste kürzer sein als die - // Stelle, an der man gerade war. - if index >= ids.count { index = 0 } + .task(id: shownID) { await advance() } + .onChange(of: items.map(\.id), initial: true) { _, ids in + // Beim ersten Zeichnen und nach jedem Aktualisieren: steht die + // gezeigte Kennung nicht mehr in der Liste, beginnt es von vorn. + if shownID == nil || !ids.contains(shownID ?? "") { shownID = ids.first } } } @@ -69,20 +75,22 @@ struct HeroCarousel: View { HStack(spacing: FlixySpacing.snug) { ForEach(items.indices, id: \.self) { position in Capsule() - .fill(position == index ? accent : Color.flixyTextMuted.opacity(0.4)) - .frame(width: position == index ? 18 : 6, height: 6) + .fill(position == currentIndex ? accent : Color.flixyTextMuted.opacity(0.4)) + .frame(width: position == currentIndex ? 18 : 6, height: 6) } } - .animation(.snappy(duration: 0.25), value: index) + .animation(.snappy(duration: 0.25), value: currentIndex) .accessibilityHidden(true) } private func advance() async { - guard items.count > 1, !reduceMotion, isAdvancing else { return } + guard items.count > 1, !reduceMotion, isAdvancing, shownID != nil else { return } try? await Task.sleep(for: Self.dwell) - guard !Task.isCancelled else { return } + guard !Task.isCancelled, isAdvancing else { return } + + let next = (currentIndex + 1) % items.count withAnimation(.easeInOut(duration: 0.5)) { - index = (index + 1) % items.count + shownID = items[next].id } } } diff --git a/Flixy/Views/Home/HomeView.swift b/Flixy/Views/Home/HomeView.swift index 116472c..25eb0d2 100644 --- a/Flixy/Views/Home/HomeView.swift +++ b/Flixy/Views/Home/HomeView.swift @@ -41,14 +41,12 @@ struct HomeView: View { // über jeden Bildschirm. Darunter liegt der Aufmacher, und der sagt // ohnehin mehr. .navigationTitle("") - .navigationBarTitleDisplayMode(.inline) + .flixyInlineTitle() // Gar kein Hintergrund statt eines weichgezeichneten: die Knöpfe // bringen ihre eigene Glasfläche mit und bleiben auch über dem Bild // lesbar. `.ultraThinMaterial` verschleierte dagegen den halben // Aufmacher. - .toolbarBackground(.hidden, for: .navigationBar) - // Helle Symbole, weil darunter ein Bild liegt und kein heller Grund. - .toolbarColorScheme(.dark, for: .navigationBar) + .flixyClearToolbarBackground() .task(id: session.serverName) { await load() } .refreshable { await load() } } diff --git a/Flixy/Views/Library/LibraryFilterSheet.swift b/Flixy/Views/Library/LibraryFilterSheet.swift index a643ada..8466de3 100644 --- a/Flixy/Views/Library/LibraryFilterSheet.swift +++ b/Flixy/Views/Library/LibraryFilterSheet.swift @@ -73,7 +73,7 @@ struct LibraryFilterSheet: View { .scrollContentBackground(.hidden) .background(Color.flixyCanvas) .navigationTitle(Text("filter.title")) - .navigationBarTitleDisplayMode(.inline) + .flixyInlineTitle() .toolbar { ToolbarItem(placement: .cancellationAction) { Button("action.cancel") { dismiss() } @@ -87,7 +87,7 @@ struct LibraryFilterSheet: View { } .task { await loadGenres() } } - .presentationDetents([.medium, .large]) + .flixyResizableSheet() } private func loadGenres() async { diff --git a/Flixy/Views/Library/LibraryHubView.swift b/Flixy/Views/Library/LibraryHubView.swift index 218f3c8..247ba99 100644 --- a/Flixy/Views/Library/LibraryHubView.swift +++ b/Flixy/Views/Library/LibraryHubView.swift @@ -47,7 +47,7 @@ struct LibraryHubView: View { .scrollContentBackground(.hidden) .background(Color.flixyCanvas) .navigationTitle(Text("sidebar.library")) - .navigationBarTitleDisplayMode(.inline) + .flixyInlineTitle() } private func symbol(for kind: MediaLibrary.Kind) -> String { diff --git a/Flixy/Views/Library/LibraryView.swift b/Flixy/Views/Library/LibraryView.swift index 76549ea..cf418a0 100644 --- a/Flixy/Views/Library/LibraryView.swift +++ b/Flixy/Views/Library/LibraryView.swift @@ -51,7 +51,7 @@ struct LibraryView: View { } .background(Color.flixyCanvas) .navigationTitle(library.title) - .navigationBarTitleDisplayMode(.inline) + .flixyInlineTitle() .toolbar { toolbar } .sheet(isPresented: $showsFilters) { LibraryFilterSheet(query: $query, libraryKind: library.kind) @@ -120,7 +120,7 @@ struct LibraryView: View { @ToolbarContentBuilder private var toolbar: some ToolbarContent { - ToolbarItem(placement: .topBarTrailing) { + ToolbarItem(placement: .flixyTrailing) { Button { showsFilters = true } label: { @@ -131,7 +131,7 @@ struct LibraryView: View { .accessibilityLabel(Text("library.filters")) } - ToolbarItem(placement: .topBarTrailing) { + ToolbarItem(placement: .flixyTrailing) { Button { layout = layout == .grid ? .list : .grid } label: { diff --git a/Flixy/Views/LiveTV/ChannelSelectionSheet.swift b/Flixy/Views/LiveTV/ChannelSelectionSheet.swift index e67c9ad..2a5f6a4 100644 --- a/Flixy/Views/LiveTV/ChannelSelectionSheet.swift +++ b/Flixy/Views/LiveTV/ChannelSelectionSheet.swift @@ -28,9 +28,9 @@ struct ChannelSelectionSheet: View { } } .navigationTitle(Text("liveTV.selection")) - .navigationBarTitleDisplayMode(.inline) + .flixyInlineTitle() .toolbar { - ToolbarItem(placement: .topBarLeading) { + ToolbarItem(placement: .flixyLeading) { // „Alle" statt „keine": eine leere Auswahl bedeutet // ohnehin alle, und ein Knopf, der scheinbar nichts tut, // wäre verwirrend. diff --git a/Flixy/Views/LiveTV/LiveTVView.swift b/Flixy/Views/LiveTV/LiveTVView.swift index de11248..1c461c7 100644 --- a/Flixy/Views/LiveTV/LiveTVView.swift +++ b/Flixy/Views/LiveTV/LiveTVView.swift @@ -96,10 +96,10 @@ struct LiveTVView: View { } .background(Color.flixyCanvas) .navigationTitle(Text("sidebar.liveTV")) - .navigationBarTitleDisplayMode(.inline) + .flixyInlineTitle() .toolbar { if case .available = availability, !channels.isEmpty { - ToolbarItem(placement: .topBarTrailing) { + ToolbarItem(placement: .flixyTrailing) { Button { showsSelection = true } label: { @@ -107,7 +107,7 @@ struct LiveTVView: View { } .accessibilityLabel(Text("liveTV.selection")) } - ToolbarItem(placement: .topBarTrailing) { + ToolbarItem(placement: .flixyTrailing) { Picker("liveTV.mode", selection: $showsGuide) { Image(systemName: "calendar.day.timeline.left").tag(true) Image(systemName: "list.bullet").tag(false) @@ -167,9 +167,11 @@ struct LiveTVView: View { .frame(maxHeight: 200) Button { - UIPasteboard.general.string = trace - .map { "\($0.path) → \($0.result)" } - .joined(separator: "\n") + FlixyClipboard.copy( + trace + .map { "\($0.path) → \($0.result)" } + .joined(separator: "\n") + ) } label: { Label("diagnostics.copy", systemImage: "doc.on.doc") .font(.flixyCaption) @@ -386,7 +388,7 @@ private struct TuneFailureSheet: View { } Button { - UIPasteboard.general.string = failure.trace.joined(separator: "\n") + FlixyClipboard.copy(failure.trace.joined(separator: "\n")) } label: { Label("diagnostics.copy", systemImage: "doc.on.doc") .font(.flixyCaption) @@ -398,13 +400,13 @@ private struct TuneFailureSheet: View { } .background(Color.flixyCanvas) .navigationTitle(Text("liveTV.tuneFailed")) - .navigationBarTitleDisplayMode(.inline) + .flixyInlineTitle() .toolbar { ToolbarItem(placement: .confirmationAction) { Button("action.close") { dismiss() } } } } - .presentationDetents([.medium, .large]) + .flixyResizableSheet() } } diff --git a/Flixy/Views/LiveTV/ProgramGuide.swift b/Flixy/Views/LiveTV/ProgramGuide.swift index e25246a..6c6b0b9 100644 --- a/Flixy/Views/LiveTV/ProgramGuide.swift +++ b/Flixy/Views/LiveTV/ProgramGuide.swift @@ -348,6 +348,6 @@ private struct ProgramDetailSheet: View { } } } - .presentationDetents([.medium, .large]) + .flixyResizableSheet() } } diff --git a/Flixy/Views/Placeholder/PlaceholderScreen.swift b/Flixy/Views/Placeholder/PlaceholderScreen.swift index e3b5e37..887962f 100644 --- a/Flixy/Views/Placeholder/PlaceholderScreen.swift +++ b/Flixy/Views/Placeholder/PlaceholderScreen.swift @@ -17,7 +17,7 @@ struct PlaceholderScreen: View { } .background(Color.flixyCanvas) .navigationTitle(destination.title) - .navigationBarTitleDisplayMode(.inline) - .toolbarBackground(Color.flixySurface, for: .navigationBar) + .flixyInlineTitle() + } } diff --git a/Flixy/Views/Player/AirPlayButton.swift b/Flixy/Views/Player/AirPlayButton.swift index 0e596c2..3df55f5 100644 --- a/Flixy/Views/Player/AirPlayButton.swift +++ b/Flixy/Views/Player/AirPlayButton.swift @@ -1,5 +1,10 @@ import AVKit import SwiftUI +#if canImport(UIKit) +import UIKit +#else +import AppKit +#endif /// Der Knopf, der die Empfängerliste öffnet. /// @@ -7,6 +12,7 @@ import SwiftUI /// die das System dafür vorsieht, und sie kennt Geräte, die eine App nie /// aufzählen könnte — Apple TVs, AirPlay-Lautsprecher, HomePods im Verbund. /// Sie nachzubauen hieße, eine unvollständige Liste zu zeigen. +#if canImport(UIKit) struct AirPlayButton: UIViewRepresentable { let tint: Color @@ -25,3 +31,20 @@ struct AirPlayButton: UIViewRepresentable { view.activeTintColor = UIColor(tint) } } +#else +struct AirPlayButton: NSViewRepresentable { + let tint: Color + + func makeNSView(context: Context) -> AVRoutePickerView { + let view = AVRoutePickerView() + view.isRoutePickerButtonBordered = false + view.setRoutePickerButtonColor(.white, for: .normal) + view.setRoutePickerButtonColor(NSColor(tint), for: .active) + return view + } + + func updateNSView(_ view: AVRoutePickerView, context: Context) { + view.setRoutePickerButtonColor(NSColor(tint), for: .active) + } +} +#endif diff --git a/Flixy/Views/Player/PlayerModel.swift b/Flixy/Views/Player/PlayerModel.swift index c5a0371..6dbfa7a 100644 --- a/Flixy/Views/Player/PlayerModel.swift +++ b/Flixy/Views/Player/PlayerModel.swift @@ -4,7 +4,9 @@ import MediaKit import Observation import FlixyDownloads import FlixyPlayback +#if canImport(FlixyVLC) import FlixyVLC +#endif import PlexKit /// Führt einen Abspielwunsch aus. @@ -712,9 +714,12 @@ final class PlayerModel { self?.restoreFromPictureInPicture?() } let controller = AVPictureInPictureController(playerLayer: surface.playerLayer) + #if os(iOS) // Beim Verlassen der App ins Bild-in-Bild wechseln, statt die - // Wiedergabe anzuhalten. + // Wiedergabe anzuhalten. Auf dem Mac verschwindet eine App nicht + // in den Hintergrund — dort startet Bild-in-Bild von Hand. controller?.canStartPictureInPictureAutomaticallyFromInline = true + #endif controller?.delegate = bridge pictureInPictureDelegate = bridge pictureInPicture = controller @@ -730,12 +735,19 @@ final class PlayerModel { } private func makeEngine(for decision: PlaybackDecision) -> any VideoPlayerEngine { + #if canImport(FlixyVLC) switch decision.engine { case .vlc: VLCPlayerEngine() // Auch der Transcode-Weg endet beim Systemplayer: er liefert HLS, und // genau darin wechselt AVPlayer die Bitrate von selbst. case .avPlayer, .none: AVPlayerEngine() } + #else + // Auf dem Mac gibt es VLC (noch) nicht: das Binärpaket ist auf iOS + // festgelegt. Was der Systemplayer nicht öffnen kann, scheitert dort + // mit einer Meldung, statt still schwarz zu bleiben. + AVPlayerEngine() + #endif } private func observe(_ engine: any VideoPlayerEngine) { diff --git a/Flixy/Views/Player/PlayerView.swift b/Flixy/Views/Player/PlayerView.swift index 3388d4a..bf34867 100644 --- a/Flixy/Views/Player/PlayerView.swift +++ b/Flixy/Views/Player/PlayerView.swift @@ -101,7 +101,11 @@ struct PlayerView: View { // Die Uhrzeit über einem laufenden Film hat dort nichts zu suchen — // und im Querformat erst recht nicht. .animation(.easeInOut(duration: 0.2), value: model.isBuffering) +#if os(iOS) + // Die Statusleiste gehört nicht über einen Film. Auf dem Mac gibt es + // keine. .statusBarHidden(true) + #endif .persistentSystemOverlays(showsControls ? .automatic : .hidden) .landscapeWhileVisible(settings.locksLandscape) .contentShape(.rect) diff --git a/Flixy/Views/Player/QualitySheet.swift b/Flixy/Views/Player/QualitySheet.swift index e49706c..7d45d0c 100644 --- a/Flixy/Views/Player/QualitySheet.swift +++ b/Flixy/Views/Player/QualitySheet.swift @@ -54,14 +54,14 @@ struct QualitySheet: View { } } .navigationTitle(Text("player.quality")) - .navigationBarTitleDisplayMode(.inline) + .flixyInlineTitle() .toolbar { ToolbarItem(placement: .confirmationAction) { Button("action.done") { dismiss() } } } } - .presentationDetents([.medium, .large]) + .flixyResizableSheet() } /// Was gerade läuft — gemessen, nicht gewünscht. diff --git a/Flixy/Views/Player/TrackPickerSheet.swift b/Flixy/Views/Player/TrackPickerSheet.swift index f7ffee3..458d44a 100644 --- a/Flixy/Views/Player/TrackPickerSheet.swift +++ b/Flixy/Views/Player/TrackPickerSheet.swift @@ -69,14 +69,14 @@ struct TrackPickerSheet: View { .scrollContentBackground(.hidden) .background(Color.flixyCanvas) .navigationTitle(Text(kind.title)) - .navigationBarTitleDisplayMode(.inline) + .flixyInlineTitle() .toolbar { ToolbarItem(placement: .confirmationAction) { Button("action.close") { dismiss() } } } } - .presentationDetents([.medium]) + .flixyMediumSheet() } private func row(title: String, isSelected: Bool, action: @escaping () -> Void) -> some View { diff --git a/Flixy/Views/Player/VideoSurface.swift b/Flixy/Views/Player/VideoSurface.swift index 51a79ff..03f01ff 100644 --- a/Flixy/Views/Player/VideoSurface.swift +++ b/Flixy/Views/Player/VideoSurface.swift @@ -1,7 +1,24 @@ import AVKit import SwiftUI import FlixyPlayback +#if canImport(UIKit) +import UIKit +#else +import AppKit +#endif +#if canImport(FlixyVLC) import FlixyVLC +#endif + +/// Die Ansichtsklasse der jeweiligen Plattform. +/// +/// UIKit und AppKit heißen verschieden und meinen dasselbe. Ein Name dafür +/// spart in dieser Datei ein Dutzend `#if`. +#if canImport(UIKit) +typealias PlatformView = UIView +#else +typealias PlatformView = NSView +#endif /// Die Bildfläche des laufendie Engine. /// @@ -22,14 +39,21 @@ struct VideoSurface: View { let surface: PlayerLayerView var body: some View { - switch engine { - case is AVPlayerEngine: - UIViewContainer(view: surface) - case let vlcEngine as VLCPlayerEngine: - UIViewContainer(view: vlcEngine.videoView) - default: + #if canImport(FlixyVLC) + if let vlcEngine = engine as? VLCPlayerEngine { + SurfaceContainer(view: vlcEngine.videoView) + } else if engine is AVPlayerEngine { + SurfaceContainer(view: surface) + } else { Color.black } + #else + if engine is AVPlayerEngine { + SurfaceContainer(view: surface) + } else { + Color.black + } + #endif } } @@ -38,10 +62,31 @@ struct VideoSurface: View { /// Der übliche Weg: eine Ebene von Hand als Unterebene einzuhängen bedeutet, /// ihre Größe bei jeder Drehung selbst nachzuführen. Als Hauptebene macht das /// UIKit. +#if canImport(UIKit) final class PlayerLayerView: UIView { override static var layerClass: AnyClass { AVPlayerLayer.self } var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer } } +#else +/// Auf dem Mac gibt es kein `layerClass`: eine `NSView` bekommt ihre Ebene +/// zugewiesen, statt ihre Art vorzugeben. +final class PlayerLayerView: NSView { + let playerLayer = AVPlayerLayer() + + override init(frame: NSRect) { + super.init(frame: frame) + wantsLayer = true + layer = playerLayer + // Ohne das bleibt die Ebene beim Vergrößern des Fensters stehen, wo + // sie war, und das Bild sitzt in der Ecke. + layer?.needsDisplayOnBoundsChange = true + autoresizingMask = [.width, .height] + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("nicht aus einem Storyboard") } +} +#endif /// Hängt eine bestehende `UIView` in den SwiftUI-Baum. /// @@ -58,25 +103,57 @@ final class PlayerLayerView: UIView { /// /// Mit eigener Hülle gehört jeder Ansicht ihre eigene, und `updateUIView` holt /// die Fläche zurück, falls sie zwischenzeitlich woanders eingehängt wurde. -private struct UIViewContainer: UIViewRepresentable { - let view: UIView +#if canImport(UIKit) +private struct SurfaceContainer: UIViewRepresentable { + let view: PlatformView - func makeUIView(context: Context) -> UIView { + func makeUIView(context: Context) -> PlatformView { let host = UIView() host.backgroundColor = .black - adopt(into: host) + adopt(view, into: host) return host } - func updateUIView(_ host: UIView, context: Context) { + func updateUIView(_ host: PlatformView, context: Context) { guard view.superview !== host else { return } - adopt(into: host) - } - - private func adopt(into host: UIView) { - view.translatesAutoresizingMaskIntoConstraints = true - view.frame = host.bounds - view.autoresizingMask = [.flexibleWidth, .flexibleHeight] - host.addSubview(view) + adopt(view, into: host) } } +#else +private struct SurfaceContainer: NSViewRepresentable { + let view: PlatformView + + func makeNSView(context: Context) -> PlatformView { + let host = NSView() + host.wantsLayer = true + host.layer?.backgroundColor = NSColor.black.cgColor + adopt(view, into: host) + return host + } + + func updateNSView(_ host: PlatformView, context: Context) { + guard view.superview !== host else { return } + adopt(view, into: host) + } +} +#endif + +/// Hängt die Fläche in ihre Hülle. +/// +/// Sie wird **nicht** unmittelbar an SwiftUI zurückgegeben, sondern in eine +/// eigene Hülle gehängt. Der Unterschied zählt genau in dem Moment, in dem es +/// Kachel und Vollbild kurz gleichzeitig gibt: eine Ansicht hat nur einen +/// Vorgesetzten, und wer sie zuletzt bekommt, gewinnt. Gab SwiftUI sie +/// unmittelbar heraus, nahm das Abräumen der einen der anderen das Bild weg. +private func adopt(_ view: PlatformView, into host: PlatformView) { + #if canImport(UIKit) + view.translatesAutoresizingMaskIntoConstraints = true + view.frame = host.bounds + view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + #else + view.translatesAutoresizingMaskIntoConstraints = true + view.frame = host.bounds + view.autoresizingMask = [.width, .height] + #endif + host.addSubview(view) +} diff --git a/Flixy/Views/Search/SearchView.swift b/Flixy/Views/Search/SearchView.swift index 34fe00f..cd3215c 100644 --- a/Flixy/Views/Search/SearchView.swift +++ b/Flixy/Views/Search/SearchView.swift @@ -26,8 +26,8 @@ struct SearchView: View { } .background(Color.flixyCanvas) .navigationTitle(Text("search.title")) - .navigationBarTitleDisplayMode(.inline) - .searchable(text: $text, placement: .navigationBarDrawer(displayMode: .always)) + .flixyInlineTitle() + .searchable(text: $text) // Jeder Tastendruck löst eine Anfrage aus — aber erst nach einer // kurzen Pause. Ohne sie schickt „Blade Runner" zwölf Anfragen los, // von denen elf verworfen werden. diff --git a/Flixy/Views/Settings/AppearanceView.swift b/Flixy/Views/Settings/AppearanceView.swift index efdb63f..d447d07 100644 --- a/Flixy/Views/Settings/AppearanceView.swift +++ b/Flixy/Views/Settings/AppearanceView.swift @@ -35,8 +35,7 @@ struct AppearanceView: View { if showsCustomField { HStack { TextField("appearance.hexPlaceholder", text: $customHex) - .textInputAutocapitalization(.characters) - .autocorrectionDisabled() + .flixyPlainEntry() .onSubmit(applyCustomColor) Button("action.apply", action: applyCustomColor) @@ -71,7 +70,7 @@ struct AppearanceView: View { .scrollContentBackground(.hidden) .background(Color.flixyCanvas) .navigationTitle(Text("appearance.title")) - .navigationBarTitleDisplayMode(.inline) + .flixyInlineTitle() .onAppear { if theme.selection.id == AccentOption.customIdentifier { customHex = theme.selection.color.hexString diff --git a/Flixy/Views/Settings/DiagnosticsView.swift b/Flixy/Views/Settings/DiagnosticsView.swift index a809caa..a8e8482 100644 --- a/Flixy/Views/Settings/DiagnosticsView.swift +++ b/Flixy/Views/Settings/DiagnosticsView.swift @@ -60,7 +60,7 @@ struct DiagnosticsView: View { Section { Button { - UIPasteboard.general.string = report(for: attempt) + FlixyClipboard.copy(report(for: attempt)) } label: { Label("diagnostics.copy", systemImage: "doc.on.doc") } @@ -92,7 +92,7 @@ struct DiagnosticsView: View { .textSelection(.enabled) Button { - UIPasteboard.general.string = liveTVStructure + FlixyClipboard.copy(liveTVStructure) } label: { Label("diagnostics.copy", systemImage: "doc.on.doc") } @@ -118,7 +118,7 @@ struct DiagnosticsView: View { .scrollContentBackground(.hidden) .background(Color.flixyCanvas) .navigationTitle(Text("diagnostics.title")) - .navigationBarTitleDisplayMode(.inline) + .flixyInlineTitle() } /// Fragt den Server, wie seine Antwort auf `/livetv/dvrs` aufgebaut ist. diff --git a/Flixy/Views/Settings/DownloadSettingsView.swift b/Flixy/Views/Settings/DownloadSettingsView.swift index b2da1d6..44d4e28 100644 --- a/Flixy/Views/Settings/DownloadSettingsView.swift +++ b/Flixy/Views/Settings/DownloadSettingsView.swift @@ -76,7 +76,7 @@ struct DownloadSettingsView: View { .scrollContentBackground(.hidden) .background(Color.flixyCanvas) .navigationTitle(Text("sidebar.downloads")) - .navigationBarTitleDisplayMode(.inline) + .flixyInlineTitle() } private var storageLimitBinding: Binding { diff --git a/Flixy/Views/Settings/HomeLayoutView.swift b/Flixy/Views/Settings/HomeLayoutView.swift index 17f1f2d..a6a825e 100644 --- a/Flixy/Views/Settings/HomeLayoutView.swift +++ b/Flixy/Views/Settings/HomeLayoutView.swift @@ -15,8 +15,12 @@ struct HomeLayoutView: View { list .background(Color.flixyCanvas) .navigationTitle(Text("homeLayout.title")) - .navigationBarTitleDisplayMode(.inline) + .flixyInlineTitle() + // Auf dem Mac gibt es den Knopf nicht — dort zieht man Zeilen ohne + // vorherigen Moduswechsel. + #if os(iOS) .toolbar { EditButton() } + #endif .task { await load() } } diff --git a/Flixy/Views/Settings/LanguagesView.swift b/Flixy/Views/Settings/LanguagesView.swift index 90f64f3..21b314f 100644 --- a/Flixy/Views/Settings/LanguagesView.swift +++ b/Flixy/Views/Settings/LanguagesView.swift @@ -62,7 +62,7 @@ struct LanguagesView: View { .scrollContentBackground(.hidden) .background(Color.flixyCanvas) .navigationTitle(Text("languages.title")) - .navigationBarTitleDisplayMode(.inline) + .flixyInlineTitle() } /// Der Name der Sprache in der Sprache des Geräts. diff --git a/Flixy/Views/Settings/PlaybackSettingsView.swift b/Flixy/Views/Settings/PlaybackSettingsView.swift index 494ff81..50487a4 100644 --- a/Flixy/Views/Settings/PlaybackSettingsView.swift +++ b/Flixy/Views/Settings/PlaybackSettingsView.swift @@ -89,7 +89,7 @@ struct PlaybackSettingsView: View { .scrollContentBackground(.hidden) .background(Color.flixyCanvas) .navigationTitle(Text("settings.playback")) - .navigationBarTitleDisplayMode(.inline) + .flixyInlineTitle() } private func rateLabel(_ rate: Double) -> String { diff --git a/Flixy/Views/Settings/ServerSettingsView.swift b/Flixy/Views/Settings/ServerSettingsView.swift index 25f2c61..99b3a4e 100644 --- a/Flixy/Views/Settings/ServerSettingsView.swift +++ b/Flixy/Views/Settings/ServerSettingsView.swift @@ -49,7 +49,7 @@ struct ServerSettingsView: View { .scrollContentBackground(.hidden) .background(Color.flixyCanvas) .navigationTitle(Text("settings.server")) - .navigationBarTitleDisplayMode(.inline) + .flixyInlineTitle() } private var kindLabel: String { diff --git a/Flixy/Views/Settings/SettingsView.swift b/Flixy/Views/Settings/SettingsView.swift index 2d919d7..353c5e5 100644 --- a/Flixy/Views/Settings/SettingsView.swift +++ b/Flixy/Views/Settings/SettingsView.swift @@ -83,7 +83,7 @@ struct SettingsView: View { .scrollContentBackground(.hidden) .background(Color.flixyCanvas) .navigationTitle(Text("sidebar.settings")) - .navigationBarTitleDisplayMode(.inline) + .flixyInlineTitle() .confirmationDialog( Text("settings.signOutConfirm"), isPresented: $showsSignOutConfirmation, diff --git a/Flixy/Views/SignIn/JellyfinSignInView.swift b/Flixy/Views/SignIn/JellyfinSignInView.swift index 2aaf012..79df354 100644 --- a/Flixy/Views/SignIn/JellyfinSignInView.swift +++ b/Flixy/Views/SignIn/JellyfinSignInView.swift @@ -32,7 +32,7 @@ struct JellyfinSignInView: View { .scrollDismissesKeyboard(.interactively) .background(Color.flixyCanvas) .navigationTitle(Text("signIn.jellyfin.title")) - .navigationBarTitleDisplayMode(.inline) + .flixyInlineTitle() .toolbar { ToolbarItem(placement: .cancellationAction) { Button("action.cancel") { @@ -102,9 +102,7 @@ struct JellyfinSignInView: View { TextField("signIn.jellyfin.addressPlaceholder", text: $model.address) .textFieldStyle(.plain) .textContentType(.URL) - .keyboardType(.URL) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() + .flixyPlainEntry(isAddress: true) .submitLabel(.go) .focused($focusedField, equals: .address) .onSubmit { Task { await model.findServer() } } @@ -188,8 +186,7 @@ struct JellyfinSignInView: View { TextField("signIn.jellyfin.userName", text: $model.userName) .textContentType(.username) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() + .flixyPlainEntry() .submitLabel(.next) .focused($focusedField, equals: .userName) .onSubmit { focusedField = .password } diff --git a/Packages/FlixyKit/Sources/FlixyDesign/PlatformShims.swift b/Packages/FlixyKit/Sources/FlixyDesign/PlatformShims.swift new file mode 100644 index 0000000..e07bc84 --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyDesign/PlatformShims.swift @@ -0,0 +1,110 @@ +import SwiftUI +#if canImport(UIKit) +import UIKit +#else +import AppKit +#endif + +/// Was auf iPhone und Mac verschieden heißt — an einer Stelle. +/// +/// Nicht jede Ansicht soll `#if os(iOS)` tragen. Ein Formular, das auf beiden +/// Plattformen dasselbe meint, soll auf beiden gleich aussehen; wo die +/// Systeme verschiedene Namen dafür haben, steht der Unterschied hier und +/// nirgends sonst. Sonst wandert er in zwanzig Dateien und wird beim +/// nächsten Bildschirm vergessen. +extension View { + /// Eine schmale Titelzeile — auf dem Mac gibt es sie nicht. + public func flixyInlineTitle() -> some View { + #if os(iOS) + navigationBarTitleDisplayMode(.inline) + #else + self + #endif + } + + /// Ein Blatt, das die halbe Höhe einnimmt. + /// + /// Auf dem Mac ist ein Blatt ein Fenster: dort zählt keine Rastung, + /// sondern eine vernünftige Mindestgröße. + public func flixyMediumSheet() -> some View { + #if os(iOS) + presentationDetents([.medium]) + #else + frame(minWidth: 420, minHeight: 460) + #endif + } + + /// Dasselbe, aber auf Wunsch bis zur vollen Höhe aufziehbar. + public func flixyResizableSheet() -> some View { + #if os(iOS) + presentationDetents([.medium, .large]) + #else + frame(minWidth: 460, minHeight: 520) + #endif + } + + /// Eingabefelder für Adressen und Namen: keine Großschreibung, keine + /// Korrektur — auf dem Mac gibt es beides ohnehin nicht als Tastatur. + public func flixyPlainEntry(isAddress: Bool = false) -> some View { + #if os(iOS) + self + .keyboardType(isAddress ? .URL : .default) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + #else + autocorrectionDisabled() + #endif + } +} + +extension View { + /// Keine eigene Fläche hinter der Titelzeile. + /// + /// Auf der Startseite liegt darunter der Aufmacher — eine weichgezeichnete + /// Leiste verschleierte die Hälfte davon. Auf dem Mac gibt es die Leiste in + /// dieser Form nicht. + public func flixyClearToolbarBackground() -> some View { + #if os(iOS) + self + .toolbarBackground(.hidden, for: .navigationBar) + .toolbarColorScheme(.dark, for: .navigationBar) + #else + self + #endif + } +} + +/// Etwas in die Zwischenablage legen. +/// +/// UIKit und AppKit heißen verschieden und meinen dasselbe; die Diagnose soll +/// deswegen nicht mit `#if` beginnen. +public enum FlixyClipboard { + public static func copy(_ text: String) { + #if canImport(UIKit) + UIPasteboard.general.string = text + #else + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + #endif + } +} + +extension ToolbarItemPlacement { + /// Rechts in der Titelzeile. + public static var flixyTrailing: ToolbarItemPlacement { + #if os(iOS) + .topBarTrailing + #else + .primaryAction + #endif + } + + /// Links in der Titelzeile. + public static var flixyLeading: ToolbarItemPlacement { + #if os(iOS) + .topBarLeading + #else + .navigation + #endif + } +} diff --git a/project.yml b/project.yml index 9b8fb2b..f82ea31 100644 --- a/project.yml +++ b/project.yml @@ -4,6 +4,7 @@ options: bundleIdPrefix: com.scarriffleservices deploymentTarget: iOS: "26.0" + macOS: "26.0" createIntermediateGroups: true groupSortPosition: top developmentLanguage: en @@ -147,6 +148,62 @@ targets: # Die Live Activity für Downloads. Eine eigene Erweiterung, weil iOS die # Darstellung im Sperrbildschirm in einem getrennten Prozess zeichnet — die # App selbst läuft zu dem Zeitpunkt meist gar nicht. + # Dieselben Quellen, eigenes Ziel. + # + # Ein gemeinsames Target für beide Plattformen wäre weniger Text, aber die + # Info.plist ist voller iOS-Angaben — Startbildschirm, erlaubte + # Ausrichtungen, Live Activities. Die müssten dann alle bedingt werden, und + # jede spätere Änderung an der iOS-Seite ginge über eine Stelle, die auch + # den Mac betrifft. Zwei Ziele sind ein paar Zeilen mehr und ein Sorge + # weniger. + # + # Ohne DownloadWidget (Live Activities gibt es auf dem Mac nicht) und ohne + # FlixyVLC: das Binärpaket ist auf iOS festgelegt. Der Player fällt dort + # allein auf den Systemplayer zurück — was er nicht öffnen kann, sagt er. + FlixyMac: + type: application + platform: macOS + sources: + - path: Flixy + excludes: + - "AppIcon.icon" + dependencies: + - package: FlixyKit + product: MediaKit + - package: FlixyKit + product: PlexKit + - package: FlixyKit + product: JellyfinKit + - package: FlixyKit + product: FlixyDesign + - package: FlixyKit + product: FlixyPlayback + - package: FlixyKit + product: FlixyDownloads + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.scarriffleservices.flixy + PRODUCT_NAME: Flixy + # Selbst signiert, ohne Entwicklerkonto: ein Build zum Ansehen soll + # nicht jedes Mal nach dem Schlüsselbund-Passwort fragen. + CODE_SIGN_STYLE: Manual + CODE_SIGN_IDENTITY: "-" + DEVELOPMENT_TEAM: "" + ENABLE_HARDENED_RUNTIME: NO + COMBINE_HIDPI_IMAGES: YES + info: + path: Flixy/Info-macOS.plist + properties: + CFBundleDisplayName: Flixy + LSApplicationCategoryType: public.app-category.entertainment + NSHumanReadableCopyright: "" + CFBundleURLTypes: + - CFBundleURLName: com.scarriffleservices.flixy.auth + CFBundleURLSchemes: + - com.scarriffleservices.flixy + NSAppTransportSecurity: + NSAllowsLocalNetworking: true + DownloadWidget: type: app-extension platform: iOS