2026-06-08 22:04:22 -07:00
import SwiftUI
import TVAnarchyCore
// / L i b r a r y b r o w s e r : a c o n t i n u e - w a t c h i n g r a i l , a s e a r c h a b l e p o s t e r g r i d , a n d a
// / s h o w → s e a s o n s → e p i s o d e s d r i l l - d o w n . P l a y r o u t e s t o t h e a c t i v e p l a y e r t a r g e t
// / ( b l a c k r e s o l v e s b y n a m e ; V L C b y f i l e p a t h ) . F u l l y u s a b l e o f f l i n e f r o m t h e
// / c a c h e d s n a p s h o t — R e f r e s h r e s c a n s ~ / m e d i a w h e n t h e m o u n t i s u p .
struct LibraryView : View {
@ Bindable var library : LibraryController
@ Bindable var player : PlayerController
2026-06-08 22:40:53 -07:00
@ Bindable var playlist : PlaylistController
2026-06-30 00:12:41 -04:00
var offline : OfflineCacheController ?
var downloads : DownloadsController ?
2026-06-08 22:04:22 -07:00
private let columns = [ GridItem ( . adaptive ( minimum : 150 ) , spacing : 16 ) ]
var body : some View {
Group {
if let show = library . selectedShow {
2026-06-30 00:12:41 -04:00
ShowDetailView ( show : show , library : library , player : player , playlist : playlist , offline : offline , downloads : downloads )
2026-06-08 22:04:22 -07:00
} else {
grid
}
}
. toolbar {
ToolbarItem ( placement : . navigation ) { breadcrumb }
ToolbarItem ( placement : . principal ) {
HeaderSearchField ( text : $ library . query , prompt : " Filter shows " )
}
ToolbarItemGroup ( placement : . primaryAction ) {
if library . refreshing { ProgressView ( ) . controlSize ( . small ) }
Button { Task { await library . refresh ( ) } } label : { Image ( systemName : " arrow.clockwise " ) }
2026-06-30 00:12:41 -04:00
. help ( " Refresh the library index from black (or local MEDIA_ROOTS) " )
2026-06-08 22:04:22 -07:00
. disabled ( library . refreshing )
HostSelector ( controller : player , compact : true )
}
}
. overlay ( alignment : . bottom ) { actionBanner }
. animation ( . default , value : player . actionMessage )
2026-06-09 05:50:02 -07:00
. task {
await library . refreshIfStale ( )
}
2026-06-08 22:04:22 -07:00
}
// / C l i c k a b l e b r e a d c r u m b : L i b r a r y / C a t e g o r y / S h o w . E a c h s e g m e n t n a v i g a t e s .
@ ViewBuilder private var breadcrumb : some View {
HStack ( spacing : 5 ) {
crumb ( " Library " ) { library . selectedShow = nil ; library . selectedCategory = nil }
if let show = library . selectedShow {
if ! show . category . isEmpty {
2026-06-09 19:51:12 -07:00
chevron ; crumb ( LibraryConfig . label ( library . type ( of : show . category ) ) ) {
library . selectedShow = nil ; library . selectedCategory = library . type ( of : show . category )
2026-06-08 22:04:22 -07:00
}
}
chevron ; Text ( show . name ) . bold ( ) . lineLimit ( 1 )
} else if let cat = library . selectedCategory {
2026-06-09 19:51:12 -07:00
chevron ; Text ( LibraryConfig . label ( cat ) ) . bold ( )
2026-06-08 22:04:22 -07:00
}
}
}
private func crumb ( _ title : String , _ action : @ escaping ( ) -> Void ) -> some View {
Button ( title , action : action ) . buttonStyle ( . plain ) . foregroundStyle ( . tint )
}
private var chevron : some View {
Image ( systemName : " chevron.right " ) . font ( . caption2 ) . foregroundStyle ( . tertiary )
}
private var grid : some View {
ScrollView {
VStack ( alignment : . leading , spacing : 20 ) {
if library . refreshing || library . rebuildingIndex {
ScanningBanner ( progress : library . scanProgress , total : library . scanTotal ,
label : library . rebuildingIndex ? " Indexing on black " : " Scanning library " )
}
categoryBar
2026-06-30 00:12:41 -04:00
if library . showContinueWatchingOnHome
&& library . selectedCategory = = nil && library . query . isEmpty
2026-06-08 22:04:22 -07:00
&& ! library . continueWatching . isEmpty {
continueRail
}
showGrid
}
. padding ( 20 )
}
}
@ ViewBuilder private var actionBanner : some View {
2026-06-30 03:25:02 -04:00
if let msg = player . actionMessage {
2026-06-30 00:12:41 -04:00
VStack ( spacing : 6 ) {
Text ( msg ) . font ( . callout )
if msg . contains ( " % " ) , let pct = parsePercent ( msg ) {
ProgressView ( value : pct )
Text ( " \( Int ( pct * 100 ) ) % " ) . font ( . caption2 ) . foregroundStyle ( . secondary )
}
}
. padding ( . horizontal , 14 ) . padding ( . vertical , 10 )
. background ( . thinMaterial , in : RoundedRectangle ( cornerRadius : 12 ) )
. padding ( . bottom , 16 )
2026-06-08 22:04:22 -07:00
. onTapGesture { copyToClipboard ( msg ) }
. help ( " Click to copy " )
. transition ( . move ( edge : . bottom ) . combined ( with : . opacity ) )
. task ( id : msg ) {
2026-06-30 00:12:41 -04:00
let secs = msg . contains ( " Downloading " ) || msg . contains ( " not downloaded " ) ? 12.0 : 4.0
try ? await Task . sleep ( for : . seconds ( secs ) )
2026-06-08 22:04:22 -07:00
player . note ( nil )
}
}
}
2026-06-30 00:12:41 -04:00
private func parsePercent ( _ msg : String ) -> Double ? {
guard let r = msg . range ( of : # " ( \ d+)% " # , options : . regularExpression ) else { return nil }
let n = msg [ r ] . dropLast ( )
return Double ( n ) . map { min ( 1 , max ( 0 , $0 / 100 ) ) }
}
2026-06-08 22:04:22 -07:00
private var categoryBar : some View {
ScrollView ( . horizontal , showsIndicators : false ) {
HStack ( spacing : 8 ) {
chip ( title : " All " , category : nil , count : library . visibleCount )
ForEach ( library . categories , id : \ . self ) { cat in
2026-06-09 19:51:12 -07:00
chip ( title : LibraryConfig . label ( cat ) , category : cat , count : library . count ( of : cat ) )
2026-06-08 22:04:22 -07:00
}
2026-06-09 19:51:12 -07:00
#if ENABLE_ADULT
2026-06-08 22:04:22 -07:00
Button { library . showPorn . toggle ( ) } label : {
Image ( systemName : library . showPorn ? " eye.slash " : " eye " )
}
. buttonStyle ( . borderless )
. help ( library . showPorn ? " Hide adult category " : " Show adult category " )
2026-06-09 19:51:12 -07:00
#endif
2026-06-08 22:04:22 -07:00
}
}
}
private func chip ( title : String , category : String ? , count : Int ) -> some View {
let selected = library . selectedCategory = = category
return Button { library . selectedCategory = category } label : {
HStack ( spacing : 5 ) {
Text ( title )
Text ( " \( count ) " ) . font ( . caption2 ) . foregroundStyle ( . secondary )
}
. padding ( . horizontal , 10 ) . padding ( . vertical , 5 )
. background ( selected ? AnyShapeStyle ( . tint . opacity ( 0.22 ) ) : AnyShapeStyle ( . quaternary ) ,
in : Capsule ( ) )
}
. buttonStyle ( . plain )
2026-06-30 00:12:41 -04:00
. help ( category = = nil ? " Show all library categories " : " Filter Library grid to \( title ) " )
2026-06-08 22:04:22 -07:00
}
private var continueRail : some View {
VStack ( alignment : . leading , spacing : 8 ) {
Text ( " Continue watching " ) . font ( . headline )
ScrollView ( . horizontal , showsIndicators : false ) {
HStack ( spacing : 12 ) {
ForEach ( library . continueWatching ) { item in
2026-06-30 00:12:41 -04:00
Button { play ( continue : item ) } label : { ContinueCard ( item : item , isDownloaded : library . isDownloaded ( path : item . path ) , downloadProgress : library . downloadProgress ( path : item . path ) ) }
2026-06-08 22:04:22 -07:00
. buttonStyle ( . plain )
}
}
}
}
}
private var showGrid : some View {
LazyVGrid ( columns : columns , spacing : 16 ) {
ForEach ( library . filteredShows ) { show in
2026-06-09 05:50:02 -07:00
ShowPoster ( show : show ,
2026-06-30 00:12:41 -04:00
watchState : show . watchState ( watchedPaths : library . playedPaths ) ,
downloadState : library . downloadState ( for : show ) ,
2026-06-09 05:50:02 -07:00
onThumbnailTap : { watchNext ( show ) } ,
onTitleTap : { library . selectedShow = show } )
2026-06-08 22:40:53 -07:00
. contextMenu {
2026-06-09 05:50:02 -07:00
Button { library . selectedShow = show } label : { Label ( " Open " , systemImage : " rectangle.expand.vertical " ) }
2026-06-08 22:40:53 -07:00
Button {
playlist . append ( show : show )
player . note ( addedNote ( for : show ) )
} label : { Label ( addToQueueLabel ( for : show ) , systemImage : " text.badge.plus " ) }
2026-06-30 00:12:41 -04:00
if show . watchState ( watchedPaths : library . playedPaths ) = = . watched {
Button { rewatch ( show ) } label : { Label ( " Rewatch from start " , systemImage : " arrow.counterclockwise " ) }
}
2026-06-08 22:40:53 -07:00
}
2026-06-08 22:04:22 -07:00
}
}
}
2026-06-08 22:40:53 -07:00
// / C o n t e x t - m e n u l a b e l : a m o v i e a d d s o n e f i l e ; a s e r i e s a d d s e v e r y e p i s o d e .
private func addToQueueLabel ( for show : CachedShow ) -> String {
guard show . kind = = . series else { return " Add to Queue " }
if let n = show . knownEpisodeCount , n > 0 { return " Add \( n ) episodes to Queue " }
return " Add show to Queue "
}
private func addedNote ( for show : CachedShow ) -> String {
if show . kind = = . series , let n = show . knownEpisodeCount , n > 0 {
return " Added \( n ) episodes of ‘ \( show . name ) ’ to the queue"
}
return " Added ‘ \( show . name ) ’ to the queue"
}
2026-06-09 05:50:02 -07:00
// / T h u m b n a i l t a p = " w a t c h n e x t " : a m o v i e j u s t p l a y s ; a s e r i e s p l a y s i t s n e x t
// / u n w a t c h e d e p i s o d e ( r e w a t c h f r o m t h e s t a r t o n c e a l l a r e s e e n ) a n d q u e u e s t h e
// / r e s t v i a t h e u n i f i e d p l a y l i s t .
private func watchNext ( _ show : CachedShow ) {
let next = show . kind = = . movie ? nil
2026-06-30 00:12:41 -04:00
: ( show . nextUnwatched ( watchedPaths : library . playedPaths ) ? ? show . orderedEpisodes . first )
2026-06-09 05:50:02 -07:00
if show . kind = = . series , let next , player . canEnqueue {
playlist . loadFromHere ( show : show , startPath : next . path )
player . setActiveContext ( series : show . name , category : show . category )
playlist . play ( on : player , resumeFirst : nil )
return
}
guard let kind = player . activeKind ,
let req = library . launchRequest ( show : show , episode : next , targetKind : kind ) else {
player . note ( " No player selected " ) ; return
}
player . launch ( req , series : show . kind = = . series ? show . name : nil , category : show . category )
}
2026-06-30 00:12:41 -04:00
private func rewatch ( _ show : CachedShow ) {
library . resetWatchState ( for : show )
// p l a y e d s t a t e i s n o w l i b r a r y . p l a y e d P a t h s ( f r o m u n i f i e d w a t c h c o n t r o l l e r ) ; b a d g e s w i l l u p d a t e o n n e x t p o l l / r e f r e s h .
// T h e n b e h a v e e x a c t l y l i k e w a t c h N e x t ( f i r s t e p i s o d e , q u e u e r e s t ) .
watchNext ( show )
}
2026-06-08 22:04:22 -07:00
private func play ( continue item : ContinueItem ) {
2026-06-09 05:50:02 -07:00
// U n i f i e d q u e u e : c o n t i n u i n g a s e r i e s q u e u e s t h e r e s t o f t h e s h o w f r o m h e r e
// ( s o S 3 f o l l o w s S 2 ) . F a l l s b a c k t o a s i n g l e l a u n c h f o r m o v i e s / h o s t s t h a t
// c a n ' t e n q u e u e . R e s u m e p o s i t i o n c o m e s f r o m t h e w a t c h l o g / V L C r e c e n t s .
if playlist . playContinue ( item , shows : library . shows , on : player ) { return }
2026-06-08 22:04:22 -07:00
guard let kind = player . activeKind ,
let req = library . launchRequest ( continue : item , targetKind : kind ) else {
player . note ( " No player selected " ) ; return
}
player . launch ( req , series : item . show , resumeSeconds : item . positionSeconds )
}
}
// MARK: - S h o w d e t a i l ( s e a s o n s → e p i s o d e s )
private struct ShowDetailView : View {
let show : CachedShow
@ Bindable var library : LibraryController
@ Bindable var player : PlayerController
2026-06-08 22:40:53 -07:00
@ Bindable var playlist : PlaylistController
2026-06-30 00:12:41 -04:00
var offline : OfflineCacheController ?
var downloads : DownloadsController ?
2026-06-08 22:04:22 -07:00
// / T h e f r a n c h i s e t i m e l i n e ( t h i s s e r i e s + r e l a t e d m o v i e s ) , c h r o n o l o g i c a l .
@ State private var franchise : [ CachedShow ] = [ ]
2026-06-30 00:12:41 -04:00
// / L i v e f r o m u n i f i e d w a t c h s o u r c e ( r e f r e s h e d b y b a c k g r o u n d p o l l + r e c o r d s ) .
private var resumeMap : [ String : Double ] { library . resumePositions ( ) }
2026-06-08 22:04:22 -07:00
private func resume ( for ep : CachedEpisode ) -> Double ? {
guard let p = resumeMap [ MediaPaths . toRemote ( ep . path ) ] , p > 1 else { return nil }
return p
}
// / M o v i e s h a v e n o e p i s o d e l i s t ( b y d e s i g n ) . S e r i e s s h o w t h e i r c o u n t , o r — w h e n
// / t h e e n t r y c a m e f r o m t h e r e g i s t r y t i t l e - l i s t r a t h e r t h a n a f i l e s y s t e m s c a n —
// / a p r o m p t t o r e s c a n , n o t a m i s l e a d i n g " o f f l i n e " .
private var subtitle : String {
2026-06-09 19:51:12 -07:00
if show . kind = = . movie { return show . category . isEmpty ? " Movie " : LibraryConfig . label ( LibraryConfig . type ( of : show . category ) ) }
2026-06-08 22:04:22 -07:00
if ! show . countSummary . isEmpty { return show . countSummary }
return " Episodes not scanned yet — Refresh on your home network "
}
var body : some View {
List {
Section {
HStack ( alignment : . top , spacing : 16 ) {
2026-06-30 00:12:41 -04:00
ShowPoster ( show : show , downloadState : library . downloadState ( for : show ) ) . frame ( width : 120 )
2026-06-08 22:04:22 -07:00
VStack ( alignment : . leading , spacing : 8 ) {
Text ( show . name ) . font ( . title2 ) . bold ( )
Text ( subtitle ) . font ( . caption ) . foregroundStyle ( . secondary )
if let overview = show . overview , ! overview . isEmpty {
Text ( overview ) . font ( . callout ) . foregroundStyle ( . secondary ) . lineLimit ( 5 )
}
2026-06-08 22:40:53 -07:00
HStack ( spacing : 10 ) {
if show . kind = = . movie {
Button { play ( episode : nil ) } label : { Label ( " Play " , systemImage : " play.fill " ) }
. buttonStyle ( . borderedProminent ) . disabled ( player . active = = nil )
} else {
Menu {
2026-06-09 05:50:02 -07:00
Button { resumeShow ( ) } label : { Label ( " Resume " , systemImage : " play.fill " ) }
Button { play ( episode : show . orderedEpisodes . first , resume : 0 ) } label : {
2026-06-08 22:40:53 -07:00
Label ( " Start from beginning " , systemImage : " backward.end.fill " )
}
2026-06-30 00:12:41 -04:00
if show . watchState ( watchedPaths : library . playedPaths ) = = . watched {
Button { library . resetWatchState ( for : show ) } label : { Label ( " Rewatch (reset state) " , systemImage : " arrow.counterclockwise " ) }
}
2026-06-08 22:40:53 -07:00
} label : { Label ( " Play " , systemImage : " play.fill " ) }
. menuStyle ( . borderlessButton ) . fixedSize ( ) . disabled ( player . active = = nil )
}
Button { addShowToQueue ( ) } label : {
Label ( show . kind = = . movie ? " Add to Queue " : " Queue all " ,
systemImage : " text.badge.plus " )
}
. buttonStyle ( . bordered )
. disabled ( show . kind = = . series && show . episodes . isEmpty )
. help ( " Add to the play queue " )
2026-06-08 22:04:22 -07:00
}
}
Spacer ( )
}
. padding ( . vertical , 4 )
}
if franchise . count > 1 {
Section ( " Franchise · chronological (drag to reorder) " ) {
ForEach ( franchise ) { item in
franchiseRow ( item )
}
. onMove { idx , dst in
franchise . move ( fromOffsets : idx , toOffset : dst )
library . reorderFranchise ( series : show , order : franchise . map ( \ . rootDir ) )
}
}
}
if show . kind = = . series {
ForEach ( show . seasons , id : \ . self ) { season in
2026-06-09 05:50:02 -07:00
Section ( show . seasonLabel ( season ) ) {
2026-06-08 22:04:22 -07:00
ForEach ( show . episodes ( inSeason : season ) ) { ep in
2026-06-30 00:12:41 -04:00
VStack ( alignment : . leading , spacing : 3 ) {
HStack {
Text ( " E \( ep . episode ) " ) . monospacedDigit ( ) . foregroundStyle ( . secondary ) . frame ( width : 38 , alignment : . leading )
Text ( ep . label ) . lineLimit ( 1 )
// C l e a r p e r - e p i s o d e d o w n l o a d s t a t u s ( H o m e / L i b r a r y p a g e s r e q u i r e m e n t ) .
// G r e e n f i l l e d = h a s l o c a l o f f l i n e c o p y ( v i a D o w n l o a d s I n d e x ) .
// B l u e w i t h p r o g r e s s = a c t i v e l y d o w n l o a d i n g i n t h e o f f l i n e c a c h e q u e u e .
if let p = library . downloadProgress ( for : ep ) ? ? ( library . isDownloaded ( ep ) ? 1.0 : nil ) {
if p >= 1 {
Image ( systemName : " arrow.down.circle.fill " )
. font ( . caption )
. foregroundStyle ( . green )
. help ( " Downloaded for offline " )
} else {
ProgressView ( value : p )
. progressViewStyle ( . linear )
. frame ( width : 22 , height : 3 )
. tint ( . blue )
Image ( systemName : " arrow.down.circle " )
. font ( . caption2 )
. foregroundStyle ( . blue )
. help ( " Downloading for offline " )
2026-06-08 22:04:22 -07:00
}
2026-06-30 00:12:41 -04:00
}
Spacer ( )
if let pos = resume ( for : ep ) {
Menu {
Button { play ( episode : ep , resume : pos ) } label : {
Label ( " Resume at \( timecode ( pos ) ) " , systemImage : " play.fill " )
}
Button { play ( episode : ep , resume : 0 ) } label : {
Label ( " Play from start " , systemImage : " backward.end.fill " )
}
} label : { Image ( systemName : " play.circle.badge.checkmark " ) }
. menuStyle ( . borderlessButton ) . fixedSize ( ) . disabled ( player . active = = nil )
} else {
Button { play ( episode : ep , resume : 0 ) } label : { Image ( systemName : " play.circle " ) }
. buttonStyle ( . borderless ) . disabled ( player . active = = nil )
}
}
if let f = watchFraction ( for : ep ) , f > 0 {
// N e t f l i x - s t y l e e p i s o d e p r o g r e s s b a r ( r e d , t h i n ) . F u l l f o r f i n i s h e d e p s ;
// p a r t i a l f o r t h o s e w i t h a c a p t u r e d r e s u m e p o s i t i o n ( l i v e - u p d a t e d w h i l e w a t c h i n g ) .
ProgressView ( value : f )
. progressViewStyle ( . linear )
. tint ( . red )
. frame ( height : 2 )
. padding ( . leading , 38 )
2026-06-08 22:04:22 -07:00
}
}
2026-06-08 22:40:53 -07:00
. contextMenu {
Button {
playlist . append ( episode : ep , of : show )
player . note ( " Added ‘ \( ep . label ) ’ to the queue" )
} label : { Label ( " Add to Queue " , systemImage : " text.badge.plus " ) }
2026-06-30 00:12:41 -04:00
Divider ( )
Button ( role : . destructive ) {
let p = ep . path
Task {
let ( ok , msg ) = await DevicesConfig . markStorageFileBroken ( MediaPaths . toRemote ( p ) )
await MainActor . run {
player . note ( ok ? msg : " Mark broken failed: \( msg ) . Manually: ssh storage 'touch \" \( p ) .broken \" ' " )
}
}
} label : {
Label ( " Mark as broken (governor skip) " , systemImage : " exclamationmark.octagon.fill " )
}
. help ( " Touches sibling .broken next to the source file on storage. The governor watch/keeper will exclude this file from prefetch forever. Use for files that refuse to play in VLC (corrupt, bad decode, frozen at 0:00, etc.). " )
Button {
let p = ep . path
Task {
let ( ok , msg ) = await DevicesConfig . unmarkStorageFileBroken ( MediaPaths . toRemote ( p ) )
await MainActor . run { player . note ( ok ? msg : " Unmark failed: \( msg ) " ) }
}
} label : {
Label ( " Unmark broken (re-enable) " , systemImage : " checkmark.circle " )
}
. help ( " Removes the .broken marker so governor watch/keeper will consider the file again. " )
2026-06-08 22:40:53 -07:00
}
2026-06-08 22:04:22 -07:00
}
}
}
}
}
. task {
franchise = library . franchiseTimeline ( for : show )
}
}
// / O n e r o w i n t h e f r a n c h i s e t i m e l i n e : t h e s e r i e s i t s e l f ( c u r r e n t , m a r k e d ) , o r a
// / r e l a t e d m o v i e ( p l a y + u n l i n k ) . Y e a r s h o w n f o r c h r o n o l o g y .
@ ViewBuilder private func franchiseRow ( _ item : CachedShow ) -> some View {
let isCurrent = item . rootDir = = show . rootDir
HStack ( spacing : 8 ) {
Image ( systemName : isCurrent ? " circle.fill " : " film " )
. font ( . caption ) . foregroundStyle ( isCurrent ? AnyShapeStyle ( . tint ) : AnyShapeStyle ( . secondary ) )
Text ( item . name ) . fontWeight ( isCurrent ? . bold : . regular ) . lineLimit ( 1 )
if let y = item . year { Text ( String ( y ) ) . font ( . caption ) . foregroundStyle ( . secondary ) }
if isCurrent {
Text ( " Series " ) . font ( . caption2 ) . padding ( . horizontal , 5 ) . padding ( . vertical , 1 )
. background ( . quaternary , in : Capsule ( ) )
}
Spacer ( )
if ! isCurrent {
Button { playItem ( item ) } label : { Image ( systemName : " play.circle " ) }
. buttonStyle ( . borderless ) . disabled ( player . active = = nil )
}
}
. contextMenu {
if ! isCurrent {
Button ( " Open " ) { library . selectedShow = item }
Button ( " Not part of this franchise " , role : . destructive ) {
library . unlinkFromFranchise ( series : show , movie : item )
franchise . removeAll { $0 . rootDir = = item . rootDir }
}
}
}
}
private func timecode ( _ s : Double ) -> String {
let i = Int ( s . rounded ( ) ) ; return String ( format : " %d:%02d " , i / 60 , i % 60 )
}
2026-06-30 00:12:41 -04:00
// / N e t f l i x - s t y l e p e r - e p i s o d e p r o g r e s s : 1 . 0 f o r f i n i s h e d ( " p l a y " m a r k e r ) ,
// / e l s e t h e l a t e s t k n o w n f r a c t i o n ( p o s / d u r ) i f w e c a p t u r e d d u r a t i o n d u r i n g
// / a l i v e r e p o r t o r f i n i s h . F a l l s b a c k t o a s m a l l " s t a r t e d " i n d i c a t o r i f o n l y
// / a r e s u m e p o s e x i s t s w i t h o u t d u r .
private func watchFraction ( for ep : CachedEpisode ) -> Double ? {
let r = MediaPaths . toRemote ( ep . path )
if library . playedPaths . contains ( r ) { return 1.0 }
if let p = library . episodeProgress [ r ] { return p . fraction }
if resumeMap [ r ] != nil { return 0.12 } // v i s i b l e " i n p r o g r e s s , d u r u n k n o w n "
return nil
}
2026-06-08 22:40:53 -07:00
// / A d d t h i s e n t r y t o t h e p l a y q u e u e : a m o v i e ' s f i l e , o r a l l o f a s e r i e s '
// / e p i s o d e s i n o r d e r . C o n f i r m s v i a t h e s a m e b a n n e r p l a y a c t i o n s u s e .
private func addShowToQueue ( ) {
playlist . append ( show : show )
if show . kind = = . series {
let n = show . knownEpisodeCount ? ? show . episodes . count
player . note ( " Added \( n ) episodes of ‘ \( show . name ) ’ to the queue" )
} else {
player . note ( " Added ‘ \( show . name ) ’ to the queue" )
}
}
2026-06-08 22:04:22 -07:00
// / P l a y a f r a n c h i s e e n t r y ( a m o v i e ) d i r e c t l y o n t h e a c t i v e h o s t .
private func playItem ( _ item : CachedShow ) {
guard let kind = player . activeKind ,
let req = library . launchRequest ( show : item , episode : nil , targetKind : kind ) else {
player . note ( " No player selected " ) ; return
}
player . launch ( req , series : item . kind = = . series ? item . name : nil , category : item . category )
}
2026-06-09 05:50:02 -07:00
// / S h o w - l e v e l R e s u m e : c o n t i n u e f r o m t h e r e s u m e t a r g e t ( i n - p r o g r e s s e p i s o d e a t i t s
// / p o s i t i o n , e l s e n e x t u n w a t c h e d ) , q u e u i n g t h e r e s t — b y p a t h , s o i t ' s c o r r e c t f o r
// / m e r g e d m u l t i - f o l d e r s h o w s .
private func resumeShow ( ) {
guard let t = library . resumeTarget ( for : show ) else {
play ( episode : show . orderedEpisodes . first , resume : 0 ) ; return
}
play ( episode : t . episode , resume : t . position )
}
2026-06-08 22:04:22 -07:00
private func play ( episode : CachedEpisode ? , resume : Double ? = nil ) {
2026-06-09 05:50:02 -07:00
// U n i f i e d p l a y l i s t : p l a y i n g a s p e c i f i c s e r i e s e p i s o d e q u e u e s t h e r e s t o f t h e
// s h o w f r o m t h e r e ( s p e c i a l s / m o v i e s l a s t ) s o i t p l a y s s t r a i g h t t h r o u g h . A
// m o v i e , o r a h o s t t h a t c a n ' t e n q u e u e , f a l l s b a c k t o a s i n g l e l a u n c h .
if show . kind = = . series , let episode , player . canEnqueue {
playlist . loadFromHere ( show : show , startPath : episode . path )
player . setActiveContext ( series : show . name , category : show . category )
playlist . play ( on : player , resumeFirst : resume )
return
}
2026-06-08 22:04:22 -07:00
guard let kind = player . activeKind ,
let req = library . launchRequest ( show : show , episode : episode , targetKind : kind ) else {
player . note ( " No player selected " ) ; return
}
player . launch ( req , series : show . kind = = . series ? show . name : nil ,
category : show . category , resumeSeconds : resume )
}
}