Files
yattee/Shared/Playlists/PlaylistsView.swift
Arkadiusz Fal 9177abb0ec Fix iOS menu text disappearing in navigation headers
Extended the ZStack overlay fix to all iOS navigation header menus
where text labels would disappear when tapping the menu:

- HomeView: "Home" title menu
- PopularView: "Popular" title with icon menu
- TrendingView: Country/flag title menu
- PlaylistsView: Playlist title with thumbnail menu
- ChannelPlaylistView: Playlist title with thumbnail menu
- OpenVideosView: Playback mode picker menu

All menus now use the same pattern as PlaybackSettings:
- Visible static label layer in ZStack
- Invisible Menu overlay with .opacity(0)
- Prevents text disappearing and resizing animations
2025-11-23 14:16:21 +01:00

450 lines
13 KiB
Swift

import Defaults
import Siesta
import SwiftUI
struct PlaylistsView: View {
@State private var selectedPlaylistID: Playlist.ID = ""
@State private var showingNewPlaylist = false
@State private var createdPlaylist: Playlist?
@State private var showingEditPlaylist = false
@State private var editedPlaylist: Playlist?
@StateObject private var channelPlaylist = Store<ChannelPlaylist>()
@StateObject private var userPlaylist = Store<Playlist>()
@ObservedObject private var accounts = AccountsModel.shared
@ObservedObject private var model = PlaylistsModel.shared
private var player = PlayerModel.shared
private var cache = PlaylistsCacheModel.shared
@Namespace private var focusNamespace
@Default(.playlistListingStyle) private var playlistListingStyle
@Default(.showCacheStatus) private var showCacheStatus
var items: [ContentItem] {
var videos = currentPlaylist?.videos ?? []
if videos.isEmpty {
videos = userPlaylist.item?.videos ?? channelPlaylist.item?.videos ?? []
if !accounts.app.userPlaylistsEndpointIncludesVideos {
var i = 0
for index in videos.indices {
var video = videos[index]
video.indexID = "\(i)"
i += 1
videos[index] = video
}
}
}
return ContentItem.array(of: videos)
}
private var resource: Resource? {
guard let playlist = currentPlaylist else { return nil }
let resource = accounts.api.playlist(playlist.id)
if accounts.app.userPlaylistsUseChannelPlaylistEndpoint {
resource?.addObserver(channelPlaylist)
} else {
resource?.addObserver(userPlaylist)
}
return resource
}
var body: some View {
SignInRequiredView(title: "Playlists".localized()) {
VStack {
VerticalCells(items: items, allowEmpty: true) { if shouldDisplayHeader { header } }
.environment(\.currentPlaylistID, currentPlaylist?.id)
.environment(\.listingStyle, playlistListingStyle)
if currentPlaylist != nil, items.isEmpty {
hintText("Playlist is empty\n\nTap and hold on a video and then \n\"Add to Playlist\"".localized())
} else if model.all.isEmpty {
hintText("You have no playlists\n\nTap on \"New Playlist\" to create one".localized())
}
}
}
.onAppear {
model.load()
loadResource()
}
.onChange(of: accounts.current) { _ in
model.load(force: true)
loadResource()
}
.onChange(of: currentPlaylist) { _ in
channelPlaylist.clear()
userPlaylist.clear()
loadResource()
}
.onChange(of: model.reloadPlaylists) { _ in
loadResource()
}
#if os(iOS)
.refreshControl { refreshControl in
model.load(force: true) {
model.reloadPlaylists.toggle()
refreshControl.endRefreshing()
}
}
.backport
.refreshable {
DispatchQueue.main.async {
model.load(force: true) { model.reloadPlaylists.toggle() }
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem {
RequestErrorButton(error: model.error)
}
ToolbarItem(placement: .principal) {
playlistsMenu
}
}
#endif
#if os(tvOS)
.fullScreenCover(isPresented: $showingNewPlaylist, onDismiss: selectCreatedPlaylist) {
PlaylistFormView(playlist: $createdPlaylist)
}
.fullScreenCover(isPresented: $showingEditPlaylist, onDismiss: selectEditedPlaylist) {
PlaylistFormView(playlist: $editedPlaylist)
}
.focusScope(focusNamespace)
#else
.background(
EmptyView()
.sheet(isPresented: $showingNewPlaylist, onDismiss: selectCreatedPlaylist) {
PlaylistFormView(playlist: $createdPlaylist)
}
)
.background(
EmptyView()
.sheet(isPresented: $showingEditPlaylist, onDismiss: selectEditedPlaylist) {
PlaylistFormView(playlist: $editedPlaylist)
}
)
#endif
#if os(macOS)
.toolbar {
ToolbarItem {
ListingStyleButtons(listingStyle: $playlistListingStyle)
}
ToolbarItem {
HideWatchedButtons()
}
ToolbarItem {
HideShortsButtons()
}
}
#else
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in
model.load()
loadResource()
}
#endif
#if !os(tvOS)
.background(
Button("Refresh") {
resource?.load()
}
.keyboardShortcut("r")
.opacity(0)
)
#endif
}
func loadResource() {
loadCachedResource()
resource?.load()
.onSuccess { response in
if let playlist: Playlist = response.typedContent() {
ChannelPlaylistsCacheModel.shared.storePlaylist(playlist: playlist.channelPlaylist)
}
}
}
func loadCachedResource() {
if !selectedPlaylistID.isEmpty,
let currentPlaylist,
let cache = ChannelPlaylistsCacheModel.shared.retrievePlaylist(currentPlaylist.channelPlaylist)
{
DispatchQueue.main.async {
self.channelPlaylist.replace(cache)
}
}
}
#if os(iOS)
var playlistsMenu: some View {
let title = currentPlaylist?.title ?? "Playlists"
return ZStack {
HStack(spacing: 12) {
HStack(spacing: 6) {
Image(systemName: "list.and.film")
Text(title)
.font(.headline)
}
.foregroundColor(.primary)
Image(systemName: "chevron.down.circle.fill")
.foregroundColor(.accentColor)
}
.imageScale(.small)
.lineLimit(1)
.frame(maxWidth: 320)
Menu {
Menu {
selectPlaylistButton
} label: {
Label(title, systemImage: "list.and.film")
}
Section {
if let currentPlaylist {
playButtons
editPlaylistButton
if let account = accounts.current {
FavoriteButton(item: FavoriteItem(section: .playlist(account.id, currentPlaylist.id)))
.id(currentPlaylist.id)
}
}
}
if accounts.signedIn {
newPlaylistButton
}
ListingStyleButtons(listingStyle: $playlistListingStyle)
Section {
HideWatchedButtons()
HideShortsButtons()
}
Section {
SettingsButtons()
}
} label: {
HStack(spacing: 12) {
HStack(spacing: 6) {
Image(systemName: "list.and.film")
Text(title)
.font(.headline)
}
.foregroundColor(.primary)
Image(systemName: "chevron.down.circle.fill")
.foregroundColor(.accentColor)
}
.imageScale(.small)
.lineLimit(1)
.frame(maxWidth: 320)
.opacity(0)
}
.disabled(!accounts.signedIn)
}
.transaction { t in t.animation = nil }
}
#endif
func hintText(_ text: String) -> some View {
VStack {
Spacer()
Text(text)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
Spacer()
}
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
#if os(macOS)
.background(Color.secondaryBackground)
#endif
}
func selectCreatedPlaylist() {
guard createdPlaylist != nil else {
return
}
model.load(force: true) {
if let id = createdPlaylist?.id {
selectedPlaylistID = id
}
self.createdPlaylist = nil
}
}
func selectEditedPlaylist() {
if editedPlaylist.isNil {
selectedPlaylistID = ""
}
model.load(force: true) {
self.selectedPlaylistID = editedPlaylist?.id ?? ""
self.editedPlaylist = nil
}
}
var selectPlaylistButton: some View {
#if os(tvOS)
Button {
guard currentPlaylist != nil else {
return
}
selectedPlaylistID = model.all.next(after: currentPlaylist!)?.id ?? ""
} label: {
Text(currentPlaylist?.title ?? "Select playlist")
.frame(maxWidth: .infinity)
}
.lineLimit(1)
.contextMenu {
ForEach(model.all) { playlist in
Button(playlist.title) {
selectedPlaylistID = playlist.id
}
}
Button("Cancel", role: .cancel) {}
}
#else
Picker("Current Playlist", selection: $selectedPlaylistID) {
ForEach(model.all) { playlist in
Text(playlist.title).tag(playlist.id)
}
}
#endif
}
var editPlaylistButton: some View {
Button(action: {
self.editedPlaylist = self.currentPlaylist
self.showingEditPlaylist = true
}) {
Label("Edit Playlist", systemImage: "rectangle.and.pencil.and.ellipsis")
}
}
var newPlaylistButton: some View {
Button(action: { self.showingNewPlaylist = true }) {
Label("New Playlist", systemImage: "plus")
}
}
private var playButtons: some View {
Group {
Button {
player.play(items.compactMap(\.video))
} label: {
Label("Play", systemImage: "play")
}
Button {
player.play(items.compactMap(\.video), shuffling: true)
} label: {
Label("Shuffle", systemImage: "shuffle")
}
}
}
private var currentPlaylist: Playlist? {
if selectedPlaylistID.isEmpty {
DispatchQueue.main.async {
self.selectedPlaylistID = model.all.first?.id ?? ""
}
}
return model.find(id: selectedPlaylistID) ?? model.all.first
}
var shouldDisplayHeader: Bool {
#if os(tvOS)
true
#else
showCacheStatus
#endif
}
var header: some View {
HStack {
#if os(tvOS)
if model.isEmpty {
Text("No Playlists")
.foregroundColor(.secondary)
} else {
selectPlaylistButton
}
if let playlist = currentPlaylist {
editPlaylistButton
FavoriteButton(item: FavoriteItem(section: .playlist(accounts.current.id, playlist.id)))
.labelStyle(.iconOnly)
playButtons
}
newPlaylistButton
Spacer()
ListingStyleButtons(listingStyle: $playlistListingStyle)
HideWatchedButtons()
HideShortsButtons()
#else
Spacer()
#endif
if let account = accounts.current, showCacheStatus {
CacheStatusHeader(
refreshTime: cache.getFormattedPlaylistTime(account: account),
isLoading: model.isLoading
)
}
#if os(tvOS)
Button {
model.load(force: true)
loadResource()
} label: {
Label("Refresh", systemImage: "arrow.clockwise")
.labelStyle(.iconOnly)
}
#endif
}
.labelStyle(.iconOnly)
.font(.caption)
.imageScale(.small)
#if os(tvOS)
.padding(.leading, 30)
.padding(.bottom, 15)
.padding(.trailing, 30)
#endif
}
}
struct PlaylistsView_Provider: PreviewProvider {
static var previews: some View {
NavigationView {
PlaylistsView()
}
}
}