Skip to content

Commit 2730675

Browse files
srv-adminsrv-admin
authored andcommitted
fix: real episode duration for progress bar, paragraph-aware show notes
The Continue Listening bar divided by a hardcoded 1800s, so every episode was treated as 30 minutes long and anything past that read as complete. Duration is now cached per guid from AVPlayer, falling back to itunes:duration; no bar is drawn when the length is genuinely unknown. Show notes carry their structure only in <p>/<br>/<li>, which stripHTML discarded along with the inline tags, fusing everything into one block. Block-level tags now map to line breaks and numeric entities are decoded. Also fixes a FeedParser case where a multi-pattern 'where' clause only guarded the last pattern, letting <content> clobber a parsed summary.
1 parent 0065966 commit 2730675

6 files changed

Lines changed: 138 additions & 30 deletions

File tree

build/podcold_ios6.ipa

19.6 KB
Binary file not shown.

podcold/podcold/Models/Episode.swift

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,39 @@ class Episode: NSObject {
1717
UserDefaults.standard.set(seconds, forKey: "pos_\(guid)")
1818
}
1919

20+
// MARK: - Duration
21+
// Feeds are inconsistent: itunes:duration may be plain seconds ("3600"),
22+
// "MM:SS" or "HH:MM:SS", and plenty of feeds omit it entirely. The real
23+
// length is only known once AVPlayer has loaded the asset, so it is cached
24+
// per guid the first time playback reports it.
25+
26+
func savedDuration() -> Double {
27+
return UserDefaults.standard.double(forKey: "dur_\(guid)")
28+
}
29+
30+
func saveDuration(_ seconds: Double) {
31+
// Written from the 1-s time observer — only touch UserDefaults on change
32+
guard seconds > 0, abs(savedDuration() - seconds) > 1 else { return }
33+
UserDefaults.standard.set(seconds, forKey: "dur_\(guid)")
34+
}
35+
36+
// Best known total length in seconds; 0 when unknown.
37+
func totalDuration() -> Double {
38+
let cached = savedDuration()
39+
return cached > 0 ? cached : Episode.parseDuration(duration)
40+
}
41+
42+
static func parseDuration(_ s: String) -> Double {
43+
let t = s.trimmingCharacters(in: .whitespacesAndNewlines)
44+
guard !t.isEmpty else { return 0 }
45+
var total: Double = 0
46+
for part in t.components(separatedBy: ":") {
47+
guard let v = Double(part) else { return 0 }
48+
total = total * 60 + v
49+
}
50+
return total
51+
}
52+
2053
// MARK: - Played tracking
2154
// savedPosition() alone cannot distinguish "never started" from "marked done"
2255
// (both are 0), so completed episodes are tracked separately by guid.

podcold/podcold/Networking/FeedParser.swift

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -160,12 +160,10 @@ class FeedParser: NSObject, XMLParserDelegate {
160160
if currentEpisode?.pubDate.isEmpty == true { currentEpisode?.pubDate = text }
161161
case "itunes:duration":
162162
currentEpisode?.duration = text
163-
case "itunes:summary" where currentEpisode?.summary.isEmpty == true:
164-
currentEpisode?.summary = text
165-
case "description" where currentEpisode?.summary.isEmpty == true:
166-
currentEpisode?.summary = text
167-
case "content", "content:encoded" where currentEpisode?.summary.isEmpty == true:
168-
currentEpisode?.summary = text
163+
// First one wins. A `where` on a multi-pattern case only guards the last
164+
// pattern, so "content" used to clobber an already-parsed summary.
165+
case "itunes:summary", "description", "content", "content:encoded":
166+
if currentEpisode?.summary.isEmpty == true { currentEpisode?.summary = text }
169167
default: break
170168
}
171169
}

podcold/podcold/Playback/AudioPlayer.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,12 @@ class AudioPlayer: NSObject {
6262
let dur = CMTimeGetSeconds(item.duration)
6363
self.progressTick += 1
6464
// Save position every 5s — avoids UserDefaults plist-flush stalls on main thread
65-
if self.progressTick % 5 == 0 { self.currentEpisode?.savePosition(cur) }
65+
if self.progressTick % 5 == 0 {
66+
self.currentEpisode?.savePosition(cur)
67+
// Cache the real length — itunes:duration is often missing or
68+
// wrong, and the Continue Listening progress bar needs a total
69+
if !dur.isNaN { self.currentEpisode?.saveDuration(dur) }
70+
}
6671
self.onProgress?(cur, dur.isNaN ? 0 : dur)
6772
// Update lock-screen scrubber every 5s — iOS interpolates elapsed in-between
6873
if self.progressTick % 5 == 0 {

podcold/podcold/ViewControllers/EpisodeDetailVC.swift

Lines changed: 89 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -180,35 +180,103 @@ class EpisodeDetailVC: UIViewController {
180180
completion: { [weak self] _ in self?.updateDownloadButton(progress: nil) })
181181
}
182182

183+
// Show notes are HTML with no literal newlines — the paragraph structure
184+
// lives entirely in the <p>/<br>/<li> tags. Dropping every tag therefore
185+
// collapsed the whole description into one run-on block, so block-level
186+
// tags are turned into line breaks before the rest are discarded.
183187
private static func stripHTML(_ s: String) -> String {
184-
// Remove tags
185188
var out = ""
189+
var tag = ""
186190
var inTag = false
187191
for c in s {
188-
if c == "<" { inTag = true }
189-
else if c == ">" { inTag = false }
190-
else if !inTag { out.append(c) }
192+
if c == "<" {
193+
inTag = true
194+
tag = ""
195+
} else if c == ">" && inTag {
196+
inTag = false
197+
out += breakFor(tag: tag)
198+
} else if inTag {
199+
tag.append(c)
200+
} else {
201+
out.append(c)
202+
}
203+
}
204+
return collapse(decodeEntities(out))
205+
}
206+
207+
// "" for inline tags (<a>, <em>, <strong>…), a line break for block ones.
208+
private static func breakFor(tag: String) -> String {
209+
var t = tag.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
210+
let closing = t.hasPrefix("/")
211+
if closing { t.removeFirst() }
212+
// Stop at the first attribute or at a self-closing slash: "br /" -> "br"
213+
let name = t.components(separatedBy: CharacterSet(charactersIn: " \t\n\r/")).first ?? ""
214+
switch name {
215+
case "br":
216+
return "\n"
217+
case "li":
218+
return closing ? "" : "\n\u{2022} "
219+
case "p", "div", "blockquote", "pre", "ul", "ol", "table", "tr",
220+
"h1", "h2", "h3", "h4", "h5", "h6":
221+
return "\n\n"
222+
default:
223+
return ""
224+
}
225+
}
226+
227+
private static func decodeEntities(_ s: String) -> String {
228+
var out = decodeNumericEntities(s)
229+
// &amp; must come last, or "&amp;lt;" would wrongly end up as "<"
230+
let named = [("&lt;", "<"), ("&gt;", ">"), ("&quot;", "\""),
231+
("&apos;", "'"), ("&nbsp;", " "),
232+
("&hellip;", "\u{2026}"), ("&mdash;", "\u{2014}"),
233+
("&ndash;", "\u{2013}"),
234+
("&rsquo;", "\u{2019}"), ("&lsquo;", "\u{2018}"),
235+
("&rdquo;", "\u{201D}"), ("&ldquo;", "\u{201C}"),
236+
("&amp;", "&")]
237+
for (entity, replacement) in named {
238+
out = out.replacingOccurrences(of: entity, with: replacement)
191239
}
192-
// Decode common HTML entities
193-
out = out.replacingOccurrences(of: "&amp;", with: "&")
194-
out = out.replacingOccurrences(of: "&lt;", with: "<")
195-
out = out.replacingOccurrences(of: "&gt;", with: ">")
196-
out = out.replacingOccurrences(of: "&quot;", with: "\"")
197-
out = out.replacingOccurrences(of: "&#39;", with: "'")
198-
out = out.replacingOccurrences(of: "&nbsp;", with: " ")
199-
// Collapse runs of whitespace/newlines left by removed block tags
200-
var result = ""
201-
var prevNewline = false
202-
for c in out {
203-
if c == "\n" || c == "\r" {
204-
if !prevNewline { result.append("\n") }
205-
prevNewline = true
240+
return out
241+
}
242+
243+
// Feeds lean heavily on &#8217; / &#x2019; for typographic punctuation —
244+
// undecoded these showed up as literal noise mid-sentence.
245+
private static func decodeNumericEntities(_ s: String) -> String {
246+
let parts = s.components(separatedBy: "&#")
247+
guard parts.count > 1 else { return s }
248+
var out = parts[0]
249+
for part in parts.dropFirst() {
250+
guard let semi = part.firstIndex(of: ";") else { out += "&#" + part; continue }
251+
var body = String(part[part.startIndex..<semi])
252+
let rest = String(part[part.index(after: semi)...])
253+
let value: UInt32?
254+
if body.hasPrefix("x") || body.hasPrefix("X") {
255+
body.removeFirst()
256+
value = UInt32(body, radix: 16)
257+
} else {
258+
value = UInt32(body, radix: 10)
259+
}
260+
if let v = value, let scalar = UnicodeScalar(v) {
261+
out += String(Character(scalar)) + rest
206262
} else {
207-
prevNewline = false
208-
result.append(c)
263+
out += "&#" + part
209264
}
210265
}
211-
return result.trimmingCharacters(in: .whitespacesAndNewlines)
266+
return out
267+
}
268+
269+
// Trim each line and allow at most one blank line between paragraphs.
270+
private static func collapse(_ s: String) -> String {
271+
let normalized = s.replacingOccurrences(of: "\r\n", with: "\n")
272+
.replacingOccurrences(of: "\r", with: "\n")
273+
var lines: [String] = []
274+
for raw in normalized.components(separatedBy: "\n") {
275+
let line = raw.trimmingCharacters(in: .whitespaces)
276+
if line.isEmpty && (lines.last?.isEmpty ?? true) { continue }
277+
lines.append(line)
278+
}
279+
return lines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
212280
}
213281

214282
@objc private func playTapped() {

podcold/podcold/ViewControllers/HomeVC.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,11 +186,15 @@ class HomeVC: UIViewController {
186186
lbl.numberOfLines = 3
187187
card.addSubview(lbl)
188188

189+
// Needs the episode's real length — a hardcoded assumed duration made
190+
// every episode past that mark read as complete.
189191
let pos = episode.savedPosition()
190-
if pos > 0 {
192+
let total = episode.totalDuration()
193+
if pos > 0 && total > 0 {
191194
let bar = UIView(frame: CGRect(x: 0, y: 98, width: 120, height: 3))
192195
bar.backgroundColor = UIColor(white: 0.2, alpha: 1)
193-
let fill = UIView(frame: CGRect(x: 0, y: 0, width: min(120, CGFloat(pos / 1800) * 120), height: 3))
196+
let fraction = min(1.0, pos / total)
197+
let fill = UIView(frame: CGRect(x: 0, y: 0, width: CGFloat(fraction) * 120, height: 3))
194198
fill.backgroundColor = UIColor(red: 0.53, green: 0.26, blue: 0.73, alpha: 1)
195199
bar.addSubview(fill)
196200
card.addSubview(bar)

0 commit comments

Comments
 (0)