mirror of
				https://github.com/yattee/yattee.git
				synced 2025-10-31 04:31:54 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			431 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			Swift
		
	
	
	
	
	
			
		
		
	
	
			431 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 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)
 | |
|                 .transaction { t in t.animation = nil }
 | |
|             }
 | |
|             .disabled(!accounts.signedIn)
 | |
|         }
 | |
|     #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()
 | |
|         }
 | |
|     }
 | |
| }
 | 
