mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-17 07:21:58 +00:00
319 lines
12 KiB
Swift
319 lines
12 KiB
Swift
import SwiftUI
|
|
|
|
struct NuvioEpisodesPanel: View {
|
|
@ObservedObject var state: NuvioPlayerState
|
|
var onDismiss: () -> Void
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
Color.black.opacity(0.52)
|
|
.onTapGesture { onDismiss() }
|
|
|
|
VStack(spacing: 0) {
|
|
if state.showEpisodeStreams {
|
|
episodeStreamsSubView
|
|
} else {
|
|
episodesListSubView
|
|
}
|
|
}
|
|
.frame(maxWidth: 520)
|
|
.frame(maxHeight: 620)
|
|
.background(Color(red: 0.12, green: 0.12, blue: 0.12))
|
|
.clipShape(RoundedRectangle(cornerRadius: 24))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 24)
|
|
.stroke(Color.white.opacity(0.1), lineWidth: 1)
|
|
)
|
|
}
|
|
}
|
|
|
|
@State private var selectedSeason: Int = 1
|
|
|
|
var episodesListSubView: some View {
|
|
let grouped = Dictionary(grouping: state.episodes.filter { $0.season != nil || $0.episode != nil }) {
|
|
($0.season ?? 0)
|
|
}
|
|
let regular = grouped.keys.filter { $0 > 0 }.sorted()
|
|
let specials = grouped.keys.filter { $0 == 0 }
|
|
let availableSeasons = regular + specials
|
|
|
|
let currentSeason: Int = {
|
|
if let sn = state.seasonNumber, availableSeasons.contains(sn) { return sn }
|
|
return availableSeasons.first ?? 1
|
|
}()
|
|
|
|
let seasonEpisodes = (grouped[selectedSeason == 0 && !availableSeasons.contains(selectedSeason) ? currentSeason : selectedSeason] ?? [])
|
|
.sorted { ($0.episode ?? 0) < ($1.episode ?? 0) }
|
|
|
|
return VStack(spacing: 0) {
|
|
HStack {
|
|
Text("Episodes")
|
|
.font(.system(size: 18, weight: .bold))
|
|
.foregroundColor(.white)
|
|
Spacer()
|
|
panelChipButton(label: "Close", icon: nil) {
|
|
onDismiss()
|
|
}
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.top, 16)
|
|
.padding(.bottom, 12)
|
|
|
|
if availableSeasons.count > 1 {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 8) {
|
|
ForEach(availableSeasons, id: \.self) { season in
|
|
let label = season == 0 ? "Specials" : "Season \(season)"
|
|
addonFilterChip(
|
|
label: label,
|
|
isSelected: selectedSeason == season,
|
|
isLoading: false,
|
|
hasError: false
|
|
) {
|
|
selectedSeason = season
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 20)
|
|
}
|
|
.padding(.bottom, 12)
|
|
}
|
|
|
|
if seasonEpisodes.isEmpty {
|
|
Spacer()
|
|
Text("No episodes available")
|
|
.font(.system(size: 14))
|
|
.foregroundColor(.white.opacity(0.6))
|
|
.frame(height: 80)
|
|
Spacer()
|
|
} else {
|
|
ScrollView {
|
|
VStack(spacing: 4) {
|
|
ForEach(seasonEpisodes) { episode in
|
|
let isCurrent = episode.season == state.seasonNumber && episode.episode == state.episodeNumber
|
|
episodeRow(episode: episode, isCurrent: isCurrent) {
|
|
state.episodeSelectedId = episode.id
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.bottom, 16)
|
|
}
|
|
}
|
|
}
|
|
.onAppear {
|
|
if let sn = state.seasonNumber, selectedSeason != sn {
|
|
selectedSeason = sn
|
|
}
|
|
}
|
|
}
|
|
|
|
var episodeStreamsSubView: some View {
|
|
VStack(spacing: 0) {
|
|
HStack {
|
|
Text("Streams")
|
|
.font(.system(size: 18, weight: .bold))
|
|
.foregroundColor(.white)
|
|
Spacer()
|
|
panelChipButton(label: "Close", icon: nil) {
|
|
onDismiss()
|
|
}
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.top, 16)
|
|
.padding(.bottom, 12)
|
|
|
|
HStack(spacing: 8) {
|
|
panelChipButton(label: "Back", icon: "arrow.left") {
|
|
state.episodeBackRequested = true
|
|
state.showEpisodeStreams = false
|
|
state.episodeStreams = []
|
|
state.episodeAddonGroups = []
|
|
}
|
|
panelChipButton(label: "Reload", icon: "arrow.clockwise") {
|
|
state.episodeReloadRequested = true
|
|
}
|
|
let infoText: String = {
|
|
var s = ""
|
|
if let sn = state.selectedEpisodeSeason, let en = state.selectedEpisodeNumber {
|
|
s = "S\(sn)E\(en)"
|
|
}
|
|
if let t = state.selectedEpisodeTitle, !t.isEmpty {
|
|
if !s.isEmpty { s += " • " }
|
|
s += t
|
|
}
|
|
return s
|
|
}()
|
|
Text(infoText)
|
|
.font(.system(size: 12))
|
|
.foregroundColor(.white.opacity(0.6))
|
|
.lineLimit(1)
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.bottom, 8)
|
|
|
|
let addonGroups = state.episodeAddonGroups
|
|
if addonGroups.count > 1 {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 8) {
|
|
addonFilterChip(
|
|
label: "All",
|
|
isSelected: state.episodeSelectedFilter == nil,
|
|
isLoading: false,
|
|
hasError: false
|
|
) {
|
|
state.episodeSelectedFilter = nil
|
|
state.episodeFilterSelectedValue = nil
|
|
state.episodeFilterChanged = true
|
|
}
|
|
ForEach(addonGroups) { group in
|
|
addonFilterChip(
|
|
label: group.addonName,
|
|
isSelected: state.episodeSelectedFilter == group.addonId,
|
|
isLoading: group.isLoading,
|
|
hasError: group.hasError
|
|
) {
|
|
state.episodeSelectedFilter = group.addonId
|
|
state.episodeFilterSelectedValue = group.addonId
|
|
state.episodeFilterChanged = true
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 20)
|
|
}
|
|
.padding(.bottom, 12)
|
|
}
|
|
|
|
let streams = state.filteredEpisodeStreams
|
|
if state.episodeStreamsLoading && streams.isEmpty {
|
|
Spacer()
|
|
ProgressView()
|
|
.progressViewStyle(.circular)
|
|
.scaleEffect(0.8)
|
|
.frame(height: 80)
|
|
Spacer()
|
|
} else if streams.isEmpty {
|
|
Spacer()
|
|
Text("No streams found")
|
|
.font(.system(size: 14))
|
|
.foregroundColor(.white.opacity(0.6))
|
|
.frame(height: 80)
|
|
Spacer()
|
|
} else {
|
|
ScrollView {
|
|
VStack(spacing: 6) {
|
|
ForEach(streams) { stream in
|
|
episodeStreamRow(stream: stream) {
|
|
state.episodeStreamSelectedUrl = stream.url
|
|
onDismiss()
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.bottom, 16)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func episodeRow(episode: NuvioEpisodeInfo, isCurrent: Bool, action: @escaping () -> Void) -> some View {
|
|
Button(action: action) {
|
|
HStack(spacing: 12) {
|
|
if let thumb = episode.thumbnail, !thumb.isEmpty {
|
|
if #available(macOS 12.0, *) {
|
|
AsyncImage(url: URL(string: thumb)) { image in
|
|
image.resizable().aspectRatio(contentMode: .fill)
|
|
} placeholder: {
|
|
Color.gray.opacity(0.3)
|
|
}
|
|
.frame(width: 80, height: 48)
|
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
} else {
|
|
Color.gray.opacity(0.3)
|
|
.frame(width: 80, height: 48)
|
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
}
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
HStack(spacing: 8) {
|
|
let episodeLabel: String = {
|
|
if let sn = episode.season, let en = episode.episode {
|
|
return "S\(sn)E\(en)"
|
|
} else if let en = episode.episode {
|
|
return "E\(en)"
|
|
}
|
|
return ""
|
|
}()
|
|
if !episodeLabel.isEmpty {
|
|
Text(episodeLabel)
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundColor(.white.opacity(0.6))
|
|
}
|
|
if isCurrent {
|
|
Text("Playing")
|
|
.font(.system(size: 9, weight: .semibold))
|
|
.foregroundColor(.white)
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 2)
|
|
.background(Color.white.opacity(0.15))
|
|
.clipShape(Capsule())
|
|
}
|
|
}
|
|
Text(episode.title)
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundColor(.white)
|
|
.lineLimit(1)
|
|
if let overview = episode.overview, !overview.isEmpty {
|
|
Text(overview)
|
|
.font(.system(size: 11))
|
|
.foregroundColor(.white.opacity(0.6))
|
|
.lineLimit(2)
|
|
}
|
|
}
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 10)
|
|
.background(isCurrent ? Color.white.opacity(0.12) : Color.clear)
|
|
.overlay(
|
|
isCurrent ?
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.stroke(Color.white.opacity(0.2), lineWidth: 1) : nil
|
|
)
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
func episodeStreamRow(stream: NuvioStreamInfo, action: @escaping () -> Void) -> some View {
|
|
Button(action: action) {
|
|
HStack(spacing: 12) {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(stream.label)
|
|
.font(.system(size: 14, weight: .medium))
|
|
.foregroundColor(.white)
|
|
.lineLimit(1)
|
|
if let sub = stream.subtitle, !sub.isEmpty, sub != stream.label {
|
|
Text(sub)
|
|
.font(.system(size: 12))
|
|
.foregroundColor(.white.opacity(0.6))
|
|
.lineLimit(2)
|
|
}
|
|
Text(stream.addonName)
|
|
.font(.system(size: 11))
|
|
.italic()
|
|
.foregroundColor(.white.opacity(0.6))
|
|
.lineLimit(1)
|
|
}
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 12)
|
|
.background(Color.white.opacity(0.06))
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|