mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-15 01:41:59 +01:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d773ba15d3 | ||
|
|
d93982baa9 | ||
|
|
3f5001b2d2 | ||
|
|
2308021aa2 | ||
|
|
602f1c9577 | ||
|
|
9132c7ebd5 | ||
|
|
94304ccc1a | ||
|
|
74cd6fd2a9 | ||
|
|
4b669168ee | ||
|
|
ba5b6a8bf2 | ||
|
|
3295eda1f0 | ||
|
|
033c8d3d71 |
No files matched your search
Generated
+42
@@ -1114,6 +1114,12 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
|
||||
|
||||
[[package]]
|
||||
name = "dbl"
|
||||
version = "0.3.2"
|
||||
@@ -2902,6 +2908,32 @@ dependencies = [
|
||||
"scopeguard",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lofty"
|
||||
version = "0.25.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35d45c446382e04663705aa8e786157f64ef1fa85755d410121ffbf08f54b0db"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"data-encoding",
|
||||
"flate2",
|
||||
"lofty_attr",
|
||||
"log",
|
||||
"ogg_pager",
|
||||
"paste",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lofty_attr"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fdbca1b60ba7cea959ffc84ac27f3dbdbacd6dfda168c7eabbc00e48654bb9fd"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.35"
|
||||
@@ -3323,6 +3355,15 @@ dependencies = [
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ogg_pager"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "166611b491f939446e64c6be930843a31eb8e6798204ec3e9f57e084c18e7446"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
@@ -3594,6 +3635,7 @@ dependencies = [
|
||||
"futures",
|
||||
"image",
|
||||
"libc",
|
||||
"lofty",
|
||||
"notify",
|
||||
"parking_lot 0.13.0",
|
||||
"pdfs-core",
|
||||
|
||||
@@ -50,6 +50,10 @@ sha1 = "0.11"
|
||||
# formats a camera or phone actually produces — the rest of the crate's codecs
|
||||
# would be dead weight in the daemon.
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "tiff", "bmp", "rayon"] }
|
||||
# Cover pictures of songs, read from their tags (ID3, FLAC, Ogg, MP4, APE) for
|
||||
# the song's thumbnail. Its default feature is required: 0.25 does not build
|
||||
# without it.
|
||||
lofty = "0.25"
|
||||
rusqlite = { version = "0.41", features = ["bundled"] }
|
||||
# keyring 4 split into `keyring-core` plus per-platform stores. The libdbus
|
||||
# Secret Service store is the one keyring 3's `sync-secret-service` wrapped, and
|
||||
|
||||
@@ -996,21 +996,15 @@ impl TokenSink {
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the unsaved rotation, if there is one. A blob that moved on, or
|
||||
/// is gone with a logout, drops it: there is nothing it may still go into.
|
||||
/// Write the unsaved rotation, if there is one. A blob that moved on drops
|
||||
/// it. A missing blob is retried: some Secret Service providers also report
|
||||
/// locked or temporarily hidden items as absent. Never recreate it here.
|
||||
fn flush(&self) -> Result<()> {
|
||||
let mut state = self.state.lock();
|
||||
let Some(tokens) = state.unsaved.clone() else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut stored = match load() {
|
||||
Ok(stored) => stored,
|
||||
Err(Error::NotLoggedIn) => {
|
||||
state.settle();
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
let mut stored = load()?;
|
||||
match take_rotation(
|
||||
&mut stored,
|
||||
&self.session_id,
|
||||
@@ -1306,6 +1300,47 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// oo7 0.7.0 reports a locked collection's items as absent (upstream #614).
|
||||
/// Treating that as logout loses the only live tokens (B284, issue #78).
|
||||
#[test]
|
||||
fn a_temporarily_missing_session_does_not_discard_a_rotation() {
|
||||
let _turn = mock_keyring();
|
||||
write_stored(&stored_with("r")).unwrap();
|
||||
let sink = Arc::new(TokenSink::new("s", "r".into()));
|
||||
let entry = keyring_entry().unwrap();
|
||||
entry
|
||||
.as_any()
|
||||
.downcast_ref::<keyring_core::mock::Cred>()
|
||||
.unwrap()
|
||||
.set_error(keyring_core::Error::NoEntry);
|
||||
|
||||
assert!(sink.rotated(tokens("a2", "r2")).is_err());
|
||||
assert!(sink.state.lock().unsaved.is_some());
|
||||
assert_eq!(load().unwrap().refresh_token, "r");
|
||||
|
||||
sink.flush().unwrap();
|
||||
assert_eq!(load().unwrap().refresh_token, "r2");
|
||||
assert!(sink.state.lock().unsaved.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retrying_a_missing_session_never_recreates_it_or_overwrites_a_new_login() {
|
||||
let _turn = mock_keyring();
|
||||
let sink = TokenSink::new("s", "r".into());
|
||||
sink.state.lock().unsaved = Some(tokens("a2", "r2"));
|
||||
|
||||
assert!(matches!(sink.flush(), Err(Error::NotLoggedIn)));
|
||||
assert!(matches!(load(), Err(Error::NotLoggedIn)));
|
||||
assert!(sink.state.lock().unsaved.is_some());
|
||||
|
||||
let mut login = stored_with("fresh");
|
||||
login.session_id = "new".into();
|
||||
write_stored(&login).unwrap();
|
||||
sink.flush().unwrap();
|
||||
assert!(sink.state.lock().unsaved.is_none());
|
||||
assert_eq!(load().unwrap().refresh_token, "fresh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unsaved_rotation_goes_only_into_the_blob_it_was_refused_for() {
|
||||
let unsaved = UnsavedRotation {
|
||||
|
||||
@@ -768,6 +768,9 @@ pub enum Topic {
|
||||
Conflicts,
|
||||
/// The activity log gained entries.
|
||||
Activity,
|
||||
/// File thumbnails were made or fetched. A front-end asks again for the
|
||||
/// tiles it still shows blank; the ones it already painted stay as they are.
|
||||
Thumbnails,
|
||||
/// A topic from a newer daemon, which this front-end cannot act on.
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
@@ -777,7 +780,7 @@ impl Topic {
|
||||
/// Every topic a front-end can act on. A new subscription is told all of
|
||||
/// them have changed, so it catches up on whatever happened while it was
|
||||
/// not connected.
|
||||
pub const ALL: [Topic; 9] = [
|
||||
pub const ALL: [Topic; 10] = [
|
||||
Topic::Files,
|
||||
Topic::Photos,
|
||||
Topic::Trash,
|
||||
@@ -787,6 +790,7 @@ impl Topic {
|
||||
Topic::Queue,
|
||||
Topic::Conflicts,
|
||||
Topic::Activity,
|
||||
Topic::Thumbnails,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1877,11 +1881,26 @@ pub fn is_video_file_name(name: &str, size: Option<u64>) -> bool {
|
||||
!transport_stream || size.is_none_or(|size| size >= TS_VIDEO_MIN_BYTES)
|
||||
}
|
||||
|
||||
/// Whether `name` is a song or other recording in a format that can carry
|
||||
/// its own cover picture: ID3 (MP3, AAC, WAV, AIFF), FLAC and Ogg pictures,
|
||||
/// MP4 `covr` (M4A, M4B), or APE tags (WavPack, Monkey's Audio).
|
||||
pub fn is_audio_file_name(name: &str) -> bool {
|
||||
let Some((_, extension)) = name.rsplit_once('.') else {
|
||||
return false;
|
||||
};
|
||||
AUDIO_COVER_EXTENSIONS.contains(&extension.to_ascii_lowercase().as_str())
|
||||
}
|
||||
|
||||
const AUDIO_COVER_EXTENSIONS: &[&str] = &[
|
||||
"aac", "aif", "aiff", "ape", "flac", "m4a", "m4b", "mp3", "oga", "ogg", "opus", "wav", "wv",
|
||||
];
|
||||
|
||||
/// Whether file listings show a thumbnail for a file named `name`, of `size`
|
||||
/// bytes when known: an image [`is_thumbnail_image_name`] accepts, or a video,
|
||||
/// whose thumbnail is a frame the daemon takes with ffmpeg.
|
||||
/// bytes when known: an image [`is_thumbnail_image_name`] accepts, a video,
|
||||
/// whose thumbnail is a frame the daemon takes with ffmpeg, or a song, whose
|
||||
/// thumbnail is its cover: the picture in its tags, or a cover image beside it.
|
||||
pub fn is_thumbnail_media_name(name: &str, size: Option<u64>) -> bool {
|
||||
is_thumbnail_image_name(name) || is_video_file_name(name, size)
|
||||
is_thumbnail_image_name(name) || is_video_file_name(name, size) || is_audio_file_name(name)
|
||||
}
|
||||
|
||||
/// Whether `name` is one of the concrete RAW formats handled by the exiftool
|
||||
@@ -2663,6 +2682,16 @@ mod tests {
|
||||
assert!(end.is_none(), "{end:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_thumbnail_change_travels_as_its_own_topic() {
|
||||
let line = serde_json::to_string(&Event::Changed {
|
||||
topics: vec![Topic::Thumbnails],
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(line, r#"{"Changed":{"topics":["thumbnails"]}}"#);
|
||||
assert!(Topic::ALL.contains(&Topic::Thumbnails));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_daemon_without_subscriptions_is_told_apart_from_a_dead_one() {
|
||||
let socket = fake_daemon("legacy", "{\"Error\":{\"message\":\"unknown request\"}}\n");
|
||||
@@ -2727,7 +2756,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listings_thumbnail_images_and_videos() {
|
||||
fn listings_thumbnail_images_videos_and_songs() {
|
||||
assert!(is_thumbnail_media_name("photo.JPG", Some(10)));
|
||||
assert!(is_thumbnail_media_name("camera.nef", None));
|
||||
assert!(is_thumbnail_media_name("clip.mp4", Some(10)));
|
||||
@@ -2736,13 +2765,24 @@ mod tests {
|
||||
Some(64 * 1024 * 1024)
|
||||
));
|
||||
assert!(!is_thumbnail_media_name("app.ts", Some(2048)));
|
||||
assert!(!is_thumbnail_media_name(
|
||||
"song.flac",
|
||||
Some(64 * 1024 * 1024)
|
||||
));
|
||||
assert!(is_thumbnail_media_name("song.flac", Some(64 * 1024 * 1024)));
|
||||
assert!(is_thumbnail_media_name("Track 01.MP3", None));
|
||||
assert!(!is_thumbnail_media_name("notes.txt", None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn songs_are_told_by_their_extension() {
|
||||
for name in [
|
||||
"a.mp3", "b.FLAC", "c.ogg", "d.oga", "e.opus", "f.m4a", "g.m4b", "h.aac", "i.wav",
|
||||
"j.aiff", "k.aif", "l.wv", "m.ape",
|
||||
] {
|
||||
assert!(is_audio_file_name(name), "{name}");
|
||||
}
|
||||
for name in ["mp3", "song.mp3.zip", "clip.mp4", "notes.txt", "cover.jpg"] {
|
||||
assert!(!is_audio_file_name(name), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep every extension accepted by the shared classifier covered. Drive
|
||||
/// activation uses this classification before it knows whether opening a
|
||||
/// file should stream through FUSE or materialise a complete cache entry;
|
||||
|
||||
@@ -23,6 +23,7 @@ libc.workspace = true
|
||||
serde_json.workspace = true
|
||||
base64.workspace = true
|
||||
image.workspace = true
|
||||
lofty.workspace = true
|
||||
notify = "8"
|
||||
parking_lot.workspace = true
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ use std::time::{Duration, Instant};
|
||||
use base64::Engine as _;
|
||||
use pdfs_core::control::{
|
||||
FileThumbRequest, MappingProgress, PhotoItem, PhotoKind, PhotoLocation, PhotoThumb, PlaceInfo,
|
||||
ThumbnailBuildStatus, Topic, is_raw_image_name, is_thumbnail_image_name,
|
||||
ThumbnailBuildStatus, Topic, is_audio_file_name, is_raw_image_name, is_thumbnail_image_name,
|
||||
is_thumbnail_media_name,
|
||||
};
|
||||
use pdfs_core::db::{self, StoredPhoto};
|
||||
@@ -76,6 +76,36 @@ const VIDEO_BOX_WALK_MAX: usize = 32;
|
||||
/// A single frame is seconds of work even for 4K; this only stops a wedged
|
||||
/// ffmpeg from holding a thumbnail permit.
|
||||
const FFMPEG_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
/// A song no larger than this is downloaded whole for its cover.
|
||||
const AUDIO_WHOLE_MAX_BYTES: u64 = 4 * 1024 * 1024;
|
||||
/// How much of a song's start is read first, to learn where its tags end.
|
||||
const AUDIO_PROBE_BYTES: u64 = 64 * 1024;
|
||||
/// How much of a song's start is read when its first bytes don't say where its
|
||||
/// tags end: an Ogg comment packet, which holds the cover, has no size up front.
|
||||
const AUDIO_HEAD_BYTES: u64 = 4 * 1024 * 1024;
|
||||
/// Read past the tags at a song's start, so the first audio frames are there
|
||||
/// for a reader that looks for them.
|
||||
const AUDIO_HEAD_SLACK: u64 = 64 * 1024;
|
||||
/// Largest start of a song read for its tags. A cover bigger than this is not
|
||||
/// worth the download for a 512-pixel thumbnail.
|
||||
const AUDIO_TAG_MAX_BYTES: u64 = 16 * 1024 * 1024;
|
||||
/// How much of a song's end is read in formats that keep their tags there
|
||||
/// (APE tags in WavPack and Monkey's Audio, `id3 ` chunks in WAV and AIFF).
|
||||
const AUDIO_TAIL_BYTES: u64 = 4 * 1024 * 1024;
|
||||
/// Names of a folder's cover image, best first, matched without case and
|
||||
/// extension. A song without a picture in its tags gets this image's.
|
||||
const FOLDER_COVER_STEMS: [&str; 6] = [
|
||||
"cover",
|
||||
"folder",
|
||||
"front",
|
||||
"album",
|
||||
"albumart",
|
||||
"albumartsmall",
|
||||
];
|
||||
/// Image types a folder cover is taken in.
|
||||
const FOLDER_COVER_EXTENSIONS: [&str; 4] = ["jpg", "jpeg", "png", "webp"];
|
||||
/// Largest folder cover image downloaded whole for a song's thumbnail.
|
||||
const FOLDER_COVER_MAX_BYTES: u64 = 32 * 1024 * 1024;
|
||||
/// Largest thumbnail attached to an upload. Proton's own clients hold theirs to
|
||||
/// this, stepping the JPEG quality down until it fits.
|
||||
const UPLOAD_THUMB_MAX_BYTES: usize = 60 * 1024;
|
||||
@@ -630,6 +660,7 @@ enum Fetched {
|
||||
/// A whole image.
|
||||
Whole(Vec<u8>),
|
||||
Video(VideoSample),
|
||||
Audio(AudioSample),
|
||||
}
|
||||
|
||||
/// The parts of a video its thumbnail needs, downloaded: the start of the
|
||||
@@ -766,13 +797,181 @@ fn video_sample_thumbnail(
|
||||
video_file_thumbnail(&file.0, ffmpeg, timeout)
|
||||
}
|
||||
|
||||
/// The parts of a song its cover needs, downloaded: the start, where most
|
||||
/// formats keep their tags, an M4A's index, and for formats that keep their
|
||||
/// tags at the end, the end.
|
||||
struct AudioSample {
|
||||
/// The whole file's length.
|
||||
size: u64,
|
||||
/// Each part and the offset it came from.
|
||||
parts: Vec<(u64, Vec<u8>)>,
|
||||
}
|
||||
|
||||
/// Where the tags at the start of a song end, read from its first `probe`
|
||||
/// bytes: past an ID3v2 tag, and past FLAC's metadata blocks, which hold its
|
||||
/// picture. `None` when the start says nothing, as for Ogg. A FLAC block
|
||||
/// header past `probe` stops the walk with a guess of [`AUDIO_HEAD_BYTES`]
|
||||
/// more.
|
||||
fn audio_tags_end(probe: &[u8]) -> Option<u64> {
|
||||
let mut offset = 0u64;
|
||||
let mut known = false;
|
||||
if probe.get(0..3) == Some(b"ID3") {
|
||||
let size = probe.get(6..10)?;
|
||||
if size.iter().any(|byte| byte & 0x80 != 0) {
|
||||
return None;
|
||||
}
|
||||
let size = size
|
||||
.iter()
|
||||
.fold(0u64, |acc, byte| (acc << 7) | u64::from(*byte));
|
||||
let footer = probe.get(5).is_some_and(|flags| flags & 0x10 != 0);
|
||||
offset = 10 + size + if footer { 10 } else { 0 };
|
||||
known = true;
|
||||
}
|
||||
let start = usize::try_from(offset).ok()?;
|
||||
if probe.get(start..start + 4) == Some(b"fLaC") {
|
||||
offset += 4;
|
||||
loop {
|
||||
let at = usize::try_from(offset).ok()?;
|
||||
let Some(header) = probe.get(at..at + 4) else {
|
||||
return Some(offset + AUDIO_HEAD_BYTES);
|
||||
};
|
||||
let len = u64::from_be_bytes([0, 0, 0, 0, 0, header[1], header[2], header[3]]);
|
||||
offset += 4 + len;
|
||||
if header[0] & 0x80 != 0 {
|
||||
return Some(offset);
|
||||
}
|
||||
}
|
||||
}
|
||||
known.then_some(offset)
|
||||
}
|
||||
|
||||
/// Whether a song in format `name` may keep its tags at its end.
|
||||
fn audio_tags_at_end(name: &str) -> bool {
|
||||
let extension = name
|
||||
.rsplit_once('.')
|
||||
.map(|(_, extension)| extension.to_ascii_lowercase())
|
||||
.unwrap_or_default();
|
||||
matches!(extension.as_str(), "wv" | "ape" | "wav" | "aif" | "aiff")
|
||||
}
|
||||
|
||||
/// Download what the cover of the song `reader` reads, named `name`, needs. A
|
||||
/// song of up to [`AUDIO_WHOLE_MAX_BYTES`] comes whole; a larger one brings its
|
||||
/// tags, never its audio.
|
||||
async fn sample_audio(reader: &dyn RevisionRead, name: &str) -> DriveResult<AudioSample> {
|
||||
let size = reader.size();
|
||||
if size <= AUDIO_WHOLE_MAX_BYTES {
|
||||
let whole = reader.read_at(0, size).await?;
|
||||
return Ok(AudioSample {
|
||||
size,
|
||||
parts: vec![(0, whole)],
|
||||
});
|
||||
}
|
||||
let probe = reader.read_at(0, AUDIO_PROBE_BYTES.min(size)).await?;
|
||||
// An M4A keeps its cover in its index, fetched below wherever it sits.
|
||||
let iso_media = probe.get(4..8) == Some(b"ftyp");
|
||||
let want = match audio_tags_end(&probe) {
|
||||
_ if iso_media => 0,
|
||||
Some(end) => end.saturating_add(AUDIO_HEAD_SLACK),
|
||||
None => AUDIO_HEAD_BYTES,
|
||||
}
|
||||
.min(AUDIO_TAG_MAX_BYTES)
|
||||
.min(size);
|
||||
let head = if want > probe.len() as u64 {
|
||||
reader.read_at(0, want).await?
|
||||
} else {
|
||||
probe
|
||||
};
|
||||
let mut parts = Vec::new();
|
||||
match iso_media_index(reader, &head, size).await? {
|
||||
Some((offset, len)) if offset.saturating_add(len) > head.len() as u64 => {
|
||||
parts.push((offset, reader.read_at(offset, len).await?));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let head_len = head.len() as u64;
|
||||
parts.push((0, head));
|
||||
if audio_tags_at_end(name) {
|
||||
let start = size.saturating_sub(AUDIO_TAIL_BYTES).max(head_len);
|
||||
if start < size {
|
||||
parts.push((start, reader.read_at(start, size - start).await?));
|
||||
}
|
||||
}
|
||||
Ok(AudioSample { size, parts })
|
||||
}
|
||||
|
||||
/// The picture a song's tags carry: the front cover, or else the first one.
|
||||
/// `None` for a song without one, or one lofty can't read.
|
||||
fn embedded_cover(path: &Path) -> Option<Vec<u8>> {
|
||||
use lofty::config::{ParseOptions, ParsingMode};
|
||||
use lofty::file::TaggedFileExt as _;
|
||||
use lofty::picture::PictureType;
|
||||
|
||||
let tagged = lofty::probe::Probe::open(path)
|
||||
.ok()?
|
||||
.options(
|
||||
ParseOptions::new()
|
||||
.read_properties(false)
|
||||
.parsing_mode(ParsingMode::Relaxed),
|
||||
)
|
||||
.guess_file_type()
|
||||
.ok()?
|
||||
.read()
|
||||
.ok()?;
|
||||
let pictures = || tagged.tags().iter().flat_map(|tag| tag.pictures());
|
||||
pictures()
|
||||
.find(|picture| picture.pic_type() == PictureType::CoverFront)
|
||||
.or_else(|| pictures().next())
|
||||
.map(|picture| picture.data().to_vec())
|
||||
.filter(|data| !data.is_empty())
|
||||
}
|
||||
|
||||
/// A thumbnail from the cover in a downloaded [`AudioSample`]'s tags. The
|
||||
/// sample is laid out in a staging file at the offsets it came from, so the
|
||||
/// tags are where the file's own headers say they are; the audio between
|
||||
/// holds no data and costs no disk. A song without a cover, or with one the
|
||||
/// image decoder can't read, is [`ScaleAttempt::Undecodable`].
|
||||
fn audio_cover_thumbnail(sample: &AudioSample, name: &str, staging_dir: &Path) -> ScaleAttempt {
|
||||
let Some(file) = RawTempFile::create_in(staging_dir, &[], name) else {
|
||||
return ScaleAttempt::Unavailable;
|
||||
};
|
||||
let laid_out = OpenOptions::new()
|
||||
.write(true)
|
||||
.open(&file.0)
|
||||
.and_then(|staged| {
|
||||
for (offset, bytes) in &sample.parts {
|
||||
staged.write_all_at(bytes, *offset)?;
|
||||
}
|
||||
staged.set_len(sample.size)
|
||||
});
|
||||
if laid_out.is_err() {
|
||||
return ScaleAttempt::Unavailable;
|
||||
}
|
||||
match embedded_cover(&file.0) {
|
||||
Some(picture) => scale_thumbnail(&picture, "cover", staging_dir),
|
||||
None => ScaleAttempt::Undecodable,
|
||||
}
|
||||
}
|
||||
|
||||
/// How good a folder cover `name` is: its place in [`FOLDER_COVER_STEMS`],
|
||||
/// lowest best, or `None` for a file that is no cover image.
|
||||
fn cover_rank(name: &str) -> Option<usize> {
|
||||
let (stem, extension) = name.rsplit_once('.')?;
|
||||
let extension = extension.to_ascii_lowercase();
|
||||
if !FOLDER_COVER_EXTENSIONS.contains(&extension.as_str()) {
|
||||
return None;
|
||||
}
|
||||
let stem = stem.to_ascii_lowercase();
|
||||
FOLDER_COVER_STEMS.iter().position(|known| *known == stem)
|
||||
}
|
||||
|
||||
/// The thumbnail to attach to an upload of the local file at `path`, which is
|
||||
/// named `name` in Drive. Without one, Drive's own apps show a generic icon and
|
||||
/// the gallery has to download the whole file to make its own.
|
||||
///
|
||||
/// Best effort: a file that isn't an image or a video, is too large to read, or
|
||||
/// can't be decoded uploads without a thumbnail, as it always did. Blocking and
|
||||
/// CPU-bound, so async callers run it on the blocking pool.
|
||||
/// Best effort: a file that isn't an image, a video or a song with a cover in
|
||||
/// its tags, is too large to read, or can't be decoded uploads without a
|
||||
/// thumbnail, as it always did. Blocking and CPU-bound, so async callers run it
|
||||
/// on the blocking pool.
|
||||
pub(crate) fn upload_thumbnails(path: &Path, name: &str, staging_dir: &Path) -> Vec<Thumbnail> {
|
||||
if is_video_name(name) && !ffmpeg_available() {
|
||||
return Vec::new();
|
||||
@@ -806,6 +1005,14 @@ fn upload_thumbnails_with(
|
||||
return Vec::new();
|
||||
};
|
||||
scale_thumbnail_unbudgeted(&bytes, name, staging_dir)
|
||||
} else if is_audio_file_name(name) {
|
||||
// Only the song's own picture: a cover image beside it is not known
|
||||
// here, where `path` may be a staging copy rather than the song's
|
||||
// folder. The daemon finds that one when a listing asks.
|
||||
match embedded_cover(path) {
|
||||
Some(picture) => scale_thumbnail(&picture, "cover", staging_dir),
|
||||
None => return Vec::new(),
|
||||
}
|
||||
} else {
|
||||
return Vec::new();
|
||||
};
|
||||
@@ -1703,6 +1910,7 @@ impl Core {
|
||||
return (uid, ThumbAttempt::Cancelled);
|
||||
}
|
||||
let video = is_video_name(&name);
|
||||
let audio = is_audio_file_name(&name);
|
||||
// Without ffmpeg a video can still have its uploader's
|
||||
// thumbnail, but Photos has asked the server already.
|
||||
let can_sample = !video || ffmpeg_available();
|
||||
@@ -1757,6 +1965,7 @@ impl Core {
|
||||
|
||||
let download_uid = uid.clone();
|
||||
let drive = core.drive.clone();
|
||||
let audio_name = name.clone();
|
||||
let download = async move {
|
||||
// Photos asked the server in one batch already. A
|
||||
// file view's or a build's file may carry a
|
||||
@@ -1776,6 +1985,11 @@ impl Core {
|
||||
if video {
|
||||
let reader = drive.open_revision(&download_uid).await?;
|
||||
sample_video(reader.as_ref()).await.map(Fetched::Video)
|
||||
} else if audio {
|
||||
let reader = drive.open_revision(&download_uid).await?;
|
||||
sample_audio(reader.as_ref(), &audio_name)
|
||||
.await
|
||||
.map(Fetched::Audio)
|
||||
} else {
|
||||
client
|
||||
.download_file(&download_uid)
|
||||
@@ -1833,7 +2047,11 @@ impl Core {
|
||||
// Decoding + scaling a 20 MP JPEG is CPU-bound and would
|
||||
// stall the runtime's worker; hand it to the blocking pool.
|
||||
let staging_dir = core.cache.raw_thumbnail_staging_dir().to_path_buf();
|
||||
let staging = staging_dir.clone();
|
||||
let made = tokio::task::spawn_blocking(move || match fetched {
|
||||
Fetched::Audio(sample) => {
|
||||
audio_cover_thumbnail(&sample, &name, &staging_dir)
|
||||
}
|
||||
Fetched::Video(sample) => video_sample_thumbnail(
|
||||
&sample,
|
||||
&name,
|
||||
@@ -1850,6 +2068,14 @@ impl Core {
|
||||
})
|
||||
.await
|
||||
.unwrap_or(ScaleAttempt::Unavailable);
|
||||
// A song without a picture of its own shows its
|
||||
// folder's cover.
|
||||
let made = match made {
|
||||
ScaleAttempt::Undecodable if audio && core.thumb_job_current(job) => {
|
||||
core.folder_cover_thumbnail(&uid, staging).await
|
||||
}
|
||||
made => made,
|
||||
};
|
||||
if !core.thumb_job_current(job) {
|
||||
return (uid, ThumbAttempt::Cancelled);
|
||||
}
|
||||
@@ -1910,9 +2136,85 @@ impl Core {
|
||||
ThumbAttempt::Cancelled => summary.cancelled += 1,
|
||||
}
|
||||
}
|
||||
// A folder on screen whose tiles were given up on, as a recursive
|
||||
// build's start does to them, fills in without being opened again.
|
||||
if summary.made > 0 {
|
||||
self.events.publish(&[Topic::Thumbnails]);
|
||||
}
|
||||
summary
|
||||
}
|
||||
|
||||
/// The cover image in the folder of the song `uid`: the best-named one of
|
||||
/// [`FOLDER_COVER_STEMS`] among its siblings the database knows.
|
||||
fn folder_cover(&self, uid: &NodeUid) -> Option<proton_drive_rs::Node> {
|
||||
let node = self.db.node_by_uid(&uid.to_string()).ok()??;
|
||||
let parent = node.parent_uid?;
|
||||
let siblings = match self.db.children_if_listed(&parent) {
|
||||
Ok(Some(children)) => children,
|
||||
_ => self.db.known_children(&parent).ok()?,
|
||||
};
|
||||
siblings
|
||||
.into_iter()
|
||||
.filter(|sibling| !sibling.is_folder() && !sibling.trashed)
|
||||
.filter_map(|sibling| cover_rank(&sibling.name).map(|rank| (rank, sibling)))
|
||||
.min_by(|(a, x), (b, y)| a.cmp(b).then_with(|| x.name.cmp(&y.name)))
|
||||
.map(|(_, cover)| cover)
|
||||
}
|
||||
|
||||
/// A thumbnail of the song `uid` made from its folder's cover image: the
|
||||
/// cover's own cached thumbnail when it has one, else the thumbnail its
|
||||
/// uploader attached, else the image itself, up to
|
||||
/// [`FOLDER_COVER_MAX_BYTES`].
|
||||
///
|
||||
/// The result is stored under the song, so a cover added to the folder or
|
||||
/// replaced later shows only once the song changes or the daemon restarts
|
||||
/// and forgets the songs it found no cover for.
|
||||
async fn folder_cover_thumbnail(&self, uid: &NodeUid, staging_dir: PathBuf) -> ScaleAttempt {
|
||||
let Some(cover) = self.folder_cover(uid) else {
|
||||
return ScaleAttempt::Undecodable;
|
||||
};
|
||||
let ttype = ThumbnailType::Thumbnail;
|
||||
let cached = self
|
||||
.cache
|
||||
.cached_thumbnail_path(&cover.uid, ttype.as_i32(), cover.modification_time)
|
||||
.and_then(|path| std::fs::read(path).ok());
|
||||
let ready = match cached {
|
||||
Some(bytes) => Some(bytes),
|
||||
None => match self.drive.download_thumbnail(&cover.uid, ttype).await {
|
||||
Ok(bytes) => bytes.filter(|bytes| !bytes.is_empty()),
|
||||
Err(e) => {
|
||||
warn!(cover = %cover.uid, error = %e, "folder cover thumbnail download failed");
|
||||
return ScaleAttempt::Unavailable;
|
||||
}
|
||||
},
|
||||
};
|
||||
if let Some(bytes) = ready {
|
||||
return match ratio_of(&bytes) {
|
||||
Some(ratio) => ScaleAttempt::Made(GeneratedThumb { bytes, ratio }),
|
||||
None => ScaleAttempt::Undecodable,
|
||||
};
|
||||
}
|
||||
let read = async {
|
||||
let reader = self.drive.open_revision(&cover.uid).await?;
|
||||
if reader.size() > FOLDER_COVER_MAX_BYTES {
|
||||
return Ok(None);
|
||||
}
|
||||
reader.read_at(0, reader.size()).await.map(Some)
|
||||
};
|
||||
let bytes = match read.await {
|
||||
Ok(Some(bytes)) => bytes,
|
||||
Ok(None) => return ScaleAttempt::Undecodable,
|
||||
Err(e) => {
|
||||
warn!(cover = %cover.uid, error = %e, "folder cover download failed");
|
||||
return ScaleAttempt::Unavailable;
|
||||
}
|
||||
};
|
||||
let name = cover.name;
|
||||
tokio::task::spawn_blocking(move || scale_thumbnail(&bytes, &name, &staging_dir))
|
||||
.await
|
||||
.unwrap_or(ScaleAttempt::Unavailable)
|
||||
}
|
||||
|
||||
pub(crate) fn thumbnail_build_status(&self) -> ThumbnailBuildStatus {
|
||||
self.thumbnail_build.lock().clone()
|
||||
}
|
||||
@@ -2454,6 +2756,10 @@ mod decode_budget_tests {
|
||||
|
||||
#[cfg(test)]
|
||||
mod thumb_tests {
|
||||
use super::{
|
||||
AUDIO_HEAD_BYTES, AUDIO_TAIL_BYTES, AudioSample, audio_cover_thumbnail, audio_tags_end,
|
||||
cover_rank, sample_audio,
|
||||
};
|
||||
use super::{
|
||||
RawTempFile, ScaleAttempt, THUMB_EDGE, UPLOAD_IMAGE_MAX_BYTES, UPLOAD_THUMB_MAX_BYTES,
|
||||
VIDEO_HEAD_BYTES, VIDEO_WHOLE_MAX_BYTES, VideoSample, can_make_thumbnail, exiftool_binary,
|
||||
@@ -2965,6 +3271,28 @@ mod thumb_tests {
|
||||
assert!(thumb.ratio > 0.0);
|
||||
}
|
||||
|
||||
/// Optional check against real songs, through the same partial download a
|
||||
/// Drive song gets. `PDFS_AUDIO_FIXTURE` lists files separated by `:`.
|
||||
#[test]
|
||||
#[ignore = "set PDFS_AUDIO_FIXTURE to songs with a cover in their tags"]
|
||||
fn real_audio_fixture_yields_its_cover() {
|
||||
let paths = std::env::var_os("PDFS_AUDIO_FIXTURE").expect("PDFS_AUDIO_FIXTURE");
|
||||
for path in std::env::split_paths(&paths) {
|
||||
let name = path.file_name().unwrap().to_string_lossy().into_owned();
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
let revision = FakeRevision::new(bytes.len() as u64).with(0, bytes);
|
||||
let sample = block_on(sample_audio(&revision, &name)).unwrap();
|
||||
let read = revision.read.load(Ordering::Relaxed);
|
||||
let attempt = audio_cover_thumbnail(&sample, &name, &std::env::temp_dir());
|
||||
assert!(
|
||||
matches!(attempt, ScaleAttempt::Made(_)),
|
||||
"{name}: no cover, read {read} of {} bytes",
|
||||
revision.size
|
||||
);
|
||||
eprintln!("{name}: read {read} of {} bytes", revision.size);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn videos_are_told_apart_by_name() {
|
||||
for name in [
|
||||
@@ -3121,4 +3449,201 @@ mod thumb_tests {
|
||||
"a small one is untouched"
|
||||
);
|
||||
}
|
||||
|
||||
/// A `width`×`height` PNG, the cover a tag carries.
|
||||
fn png(width: u32, height: u32) -> Vec<u8> {
|
||||
let mut bytes = Vec::new();
|
||||
image::DynamicImage::ImageRgb8(image::RgbImage::new(width, height))
|
||||
.write_to(
|
||||
&mut std::io::Cursor::new(&mut bytes),
|
||||
image::ImageFormat::Png,
|
||||
)
|
||||
.unwrap();
|
||||
bytes
|
||||
}
|
||||
|
||||
/// An ID3v2.3 tag holding `picture` as its front cover.
|
||||
fn id3_with_cover(picture: &[u8]) -> Vec<u8> {
|
||||
let mut body = vec![0u8];
|
||||
body.extend_from_slice(b"image/png\0");
|
||||
body.push(3);
|
||||
body.push(0);
|
||||
body.extend_from_slice(picture);
|
||||
let mut frame = b"APIC".to_vec();
|
||||
frame.extend_from_slice(&(body.len() as u32).to_be_bytes());
|
||||
frame.extend_from_slice(&[0, 0]);
|
||||
frame.extend_from_slice(&body);
|
||||
let len = frame.len() as u32;
|
||||
let mut tag = b"ID3\x03\x00\x00".to_vec();
|
||||
tag.extend((0..4).rev().map(|i| ((len >> (7 * i)) & 0x7f) as u8));
|
||||
tag.extend_from_slice(&frame);
|
||||
tag
|
||||
}
|
||||
|
||||
/// Ten silent MPEG-1 Layer III frames at 128 kbit/s and 44.1 kHz.
|
||||
fn mpeg_frames() -> Vec<u8> {
|
||||
let mut frame = vec![0u8; 417];
|
||||
frame[..4].copy_from_slice(&[0xFF, 0xFB, 0x90, 0x64]);
|
||||
frame.repeat(10)
|
||||
}
|
||||
|
||||
/// A FLAC stream's metadata: STREAMINFO, then `picture` as its front cover.
|
||||
fn flac_with_cover(picture: &[u8]) -> Vec<u8> {
|
||||
let mut info = vec![0x10, 0x00, 0x10, 0x00, 0, 0, 0, 0, 0, 0];
|
||||
let packed: u64 = (44_100 << 44) | (1 << 41) | (15 << 36);
|
||||
info.extend_from_slice(&packed.to_be_bytes());
|
||||
info.extend_from_slice(&[0; 16]);
|
||||
let mut block = Vec::new();
|
||||
block.extend_from_slice(&3u32.to_be_bytes());
|
||||
block.extend_from_slice(&9u32.to_be_bytes());
|
||||
block.extend_from_slice(b"image/png");
|
||||
block.extend_from_slice(&0u32.to_be_bytes());
|
||||
for value in [16u32, 8, 24, 0, picture.len() as u32] {
|
||||
block.extend_from_slice(&value.to_be_bytes());
|
||||
}
|
||||
block.extend_from_slice(picture);
|
||||
let mut out = b"fLaC".to_vec();
|
||||
out.push(0);
|
||||
out.extend_from_slice(&(info.len() as u32).to_be_bytes()[1..]);
|
||||
out.extend_from_slice(&info);
|
||||
out.push(0x80 | 6);
|
||||
out.extend_from_slice(&(block.len() as u32).to_be_bytes()[1..]);
|
||||
out.extend_from_slice(&block);
|
||||
out
|
||||
}
|
||||
|
||||
fn whole_song(bytes: Vec<u8>) -> AudioSample {
|
||||
AudioSample {
|
||||
size: bytes.len() as u64,
|
||||
parts: vec![(0, bytes)],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_song_shows_the_cover_in_its_tags() {
|
||||
let tmp = scratch("song-cover");
|
||||
std::fs::create_dir_all(&tmp).unwrap();
|
||||
let mut mp3 = id3_with_cover(&png(16, 8));
|
||||
mp3.extend(mpeg_frames());
|
||||
let ScaleAttempt::Made(thumb) = audio_cover_thumbnail(&whole_song(mp3), "song.mp3", &tmp)
|
||||
else {
|
||||
panic!("an ID3 cover scales");
|
||||
};
|
||||
assert!((thumb.ratio - 2.0).abs() < 1e-6);
|
||||
|
||||
let ScaleAttempt::Made(thumb) =
|
||||
audio_cover_thumbnail(&whole_song(flac_with_cover(&png(8, 16))), "song.flac", &tmp)
|
||||
else {
|
||||
panic!("a FLAC picture scales");
|
||||
};
|
||||
assert!((thumb.ratio - 0.5).abs() < 1e-6);
|
||||
std::fs::remove_dir_all(&tmp).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_song_without_a_cover_is_no_thumbnail_of_its_own() {
|
||||
let tmp = scratch("song-bare");
|
||||
std::fs::create_dir_all(&tmp).unwrap();
|
||||
let attempt = audio_cover_thumbnail(&whole_song(mpeg_frames()), "song.mp3", &tmp);
|
||||
assert!(matches!(attempt, ScaleAttempt::Undecodable));
|
||||
std::fs::remove_dir_all(&tmp).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_start_of_a_song_says_where_its_tags_end() {
|
||||
let tag = id3_with_cover(&png(4, 4));
|
||||
assert_eq!(audio_tags_end(&tag), Some(tag.len() as u64));
|
||||
let flac = flac_with_cover(&png(4, 4));
|
||||
assert_eq!(audio_tags_end(&flac), Some(flac.len() as u64));
|
||||
// FLAC after an ID3 tag, as some taggers write it.
|
||||
let both = [tag.clone(), flac.clone()].concat();
|
||||
assert_eq!(audio_tags_end(&both), Some(both.len() as u64));
|
||||
// The picture's block header cut off by the end of the probe, just
|
||||
// past STREAMINFO: a guess past it.
|
||||
let cut = &flac[..4 + 4 + 34 + 2];
|
||||
assert!(audio_tags_end(cut).is_some_and(|end| end > cut.len() as u64));
|
||||
assert_eq!(audio_tags_end(b"OggS\0\0\0\0"), None);
|
||||
assert_eq!(
|
||||
audio_tags_end(b"ID3\x03\x00\x00\x80\0\0\0"),
|
||||
None,
|
||||
"not synchsafe"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_large_song_downloads_its_tags_not_its_audio() {
|
||||
const SIZE: u64 = 300 << 20;
|
||||
let mut start = id3_with_cover(&png(16, 8));
|
||||
start.extend(mpeg_frames());
|
||||
let revision = FakeRevision::new(SIZE).with(0, start);
|
||||
let sample = block_on(sample_audio(&revision, "long mix.mp3")).unwrap();
|
||||
let read = revision.read.load(Ordering::Relaxed);
|
||||
assert!(read < 1 << 20, "read {read} bytes");
|
||||
|
||||
let tmp = scratch("song-sparse");
|
||||
std::fs::create_dir_all(&tmp).unwrap();
|
||||
let ScaleAttempt::Made(thumb) = audio_cover_thumbnail(&sample, "long mix.mp3", &tmp) else {
|
||||
panic!("the cover comes from the start alone");
|
||||
};
|
||||
assert!((thumb.ratio - 2.0).abs() < 1e-6);
|
||||
std::fs::remove_dir_all(&tmp).unwrap();
|
||||
|
||||
// Ogg says nothing up front: a bounded head. WavPack keeps its tags at
|
||||
// the end: the tail too.
|
||||
let revision = FakeRevision::new(SIZE);
|
||||
block_on(sample_audio(&revision, "live.ogg")).unwrap();
|
||||
assert!(revision.read.load(Ordering::Relaxed) <= AUDIO_HEAD_BYTES + (64 << 10));
|
||||
let revision = FakeRevision::new(SIZE);
|
||||
let sample = block_on(sample_audio(&revision, "live.wv")).unwrap();
|
||||
assert!(
|
||||
sample
|
||||
.parts
|
||||
.iter()
|
||||
.any(|(offset, bytes)| *offset == SIZE - AUDIO_TAIL_BYTES
|
||||
&& bytes.len() as u64 == AUDIO_TAIL_BYTES)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_uploaded_song_carries_its_cover() {
|
||||
let tmp = scratch("upload-song");
|
||||
std::fs::create_dir_all(&tmp).unwrap();
|
||||
// A staging blob has no extension: the format comes from the bytes.
|
||||
let blob = tmp.join("blob");
|
||||
let mut mp3 = id3_with_cover(&png(64, 64));
|
||||
mp3.extend(mpeg_frames());
|
||||
std::fs::write(&blob, mp3).unwrap();
|
||||
let ffmpeg = fake_ffmpeg(&tmp, "exit 1");
|
||||
let thumbs = upload_thumbnails_with(&blob, "song.mp3", &tmp, ffmpeg.as_os_str());
|
||||
let [thumbnail] = thumbs.as_slice() else {
|
||||
panic!("one thumbnail, got {}", thumbs.len());
|
||||
};
|
||||
assert!(matches!(thumbnail.thumbnail_type, ThumbnailType::Thumbnail));
|
||||
assert_eq!(ratio_of(&thumbnail.content), Some(1.0));
|
||||
|
||||
std::fs::write(&blob, mpeg_frames()).unwrap();
|
||||
assert!(
|
||||
upload_thumbnails_with(&blob, "song.mp3", &tmp, ffmpeg.as_os_str()).is_empty(),
|
||||
"a song without a cover uploads bare"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_cover_is_found_by_its_usual_names() {
|
||||
assert_eq!(cover_rank("cover.jpg"), Some(0));
|
||||
assert_eq!(cover_rank("Cover.JPG"), Some(0));
|
||||
assert_eq!(cover_rank("folder.png"), Some(1));
|
||||
assert_eq!(cover_rank("AlbumArtSmall.jpg"), Some(5));
|
||||
assert!(cover_rank("cover.jpg") < cover_rank("front.webp"));
|
||||
for name in [
|
||||
"cover.txt",
|
||||
"my cover.jpg",
|
||||
"cover",
|
||||
"back.jpg",
|
||||
"cover.jpg.part",
|
||||
] {
|
||||
assert_eq!(cover_rank(name), None, "{name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3732,6 +3732,88 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A song with no picture in its tags shows the cover image in its folder,
|
||||
/// and the daemon tells a following front-end that a thumbnail landed, so
|
||||
/// a folder on screen fills in without being opened again.
|
||||
#[test]
|
||||
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
||||
fn a_song_without_a_cover_shows_its_folders_and_says_so() {
|
||||
let dir = scratch("song-cover");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
// Silent MPEG-1 Layer III frames, without any tag.
|
||||
let mut frame = vec![0u8; 417];
|
||||
frame[..4].copy_from_slice(&[0xFF, 0xFB, 0x90, 0x64]);
|
||||
let song = frame.repeat(64);
|
||||
let mut cover = Vec::new();
|
||||
image::codecs::jpeg::JpegEncoder::new(&mut cover)
|
||||
.encode_image(&image::RgbImage::new(300, 300))
|
||||
.unwrap();
|
||||
|
||||
let drive = FakeDrive::new();
|
||||
drive.device().write("track one.mp3", &song);
|
||||
drive.device().write("Cover.jpg", &cover);
|
||||
let client = drive.client(1, Faults::lan());
|
||||
let daemon = Daemon::start(&dir.join("daemon"), client).unwrap();
|
||||
|
||||
let landed = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let mut subscription = control::subscribe(&daemon.socket).unwrap();
|
||||
let flag = landed.clone();
|
||||
std::thread::spawn(move || {
|
||||
// The first change names every topic: a new subscription catches up.
|
||||
let mut caught_up = false;
|
||||
while let Ok(Some(event)) = subscription.next_event() {
|
||||
if let control::Event::Changed { topics } = event {
|
||||
if caught_up && topics.contains(&control::Topic::Thumbnails) {
|
||||
flag.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
caught_up = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let Ok(Response::Entries { entries }) = daemon.request(&Request::ListDir {
|
||||
path: String::new(),
|
||||
}) else {
|
||||
panic!("listing");
|
||||
};
|
||||
let song = entries
|
||||
.iter()
|
||||
.find(|entry| entry.name == "track one.mp3")
|
||||
.expect("the song is listed");
|
||||
let items = vec![control::FileThumbRequest {
|
||||
uid: song.uid.clone(),
|
||||
modified: song.modified,
|
||||
name: song.name.clone(),
|
||||
}];
|
||||
let Ok(Response::FileThumbGeneration { generation }) =
|
||||
daemon.request(&Request::ReserveFileThumbGeneration)
|
||||
else {
|
||||
panic!("generation");
|
||||
};
|
||||
let mut path = None;
|
||||
let settled = wait_until(Duration::from_secs(60), || {
|
||||
let Ok(Response::Thumbs { items: replies }) = daemon.request(&Request::FileThumbs {
|
||||
items: items.clone(),
|
||||
generation,
|
||||
}) else {
|
||||
return false;
|
||||
};
|
||||
path = replies.first().and_then(|reply| reply.path.clone());
|
||||
path.is_some()
|
||||
});
|
||||
assert!(settled, "the song got no thumbnail");
|
||||
let thumb = image::load_from_memory(&std::fs::read(path.unwrap()).unwrap()).unwrap();
|
||||
assert_eq!(thumb.width(), thumb.height(), "the square cover");
|
||||
assert!(
|
||||
wait_until(Duration::from_secs(15), || landed
|
||||
.load(std::sync::atomic::Ordering::SeqCst)),
|
||||
"no thumbnails change was published"
|
||||
);
|
||||
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A video in My files gets a thumbnail from a frame of its own, but a
|
||||
/// recording can be gigabytes: only its start, and an MP4's index, are
|
||||
/// downloaded for it. A video with a thumbnail from its uploader is not
|
||||
|
||||
@@ -61,6 +61,9 @@ scale.browser-status-meter trough { min-width: 140px; min-height: 6px; }
|
||||
|
||||
/* Photo viewer: always light-on-dark, whatever the system style */
|
||||
.photo-viewer-window { background-color: #111014; }
|
||||
.media-player-window video { background-color: black; }
|
||||
.media-player-note { color: rgba(255, 255, 255, 0.5); }
|
||||
.media-player-note .file-thumbnail { border-radius: 12px; }
|
||||
.viewer-top-bar { background: linear-gradient(to bottom, rgba(0, 0, 0, 0.85), rgba(0, 0, 0, 0.45) 60%, rgba(0, 0, 0, 0)); padding: 10px 16px 40px 20px; color: white; }
|
||||
.viewer-title { font-weight: 700; font-size: 1.05rem; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); color: white; }
|
||||
.viewer-counter { font-size: 0.8rem; color: rgba(255, 255, 255, 0.72); text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); }
|
||||
|
||||
@@ -128,6 +128,10 @@ fn follow_changes(ui: &Rc<Ui>, state: &FeedState, topics: &[Topic]) {
|
||||
if changed(Topic::Shares) {
|
||||
refresh_invitations_badge(ui, Duration::ZERO);
|
||||
}
|
||||
// On any page: a grid, a list or a player may show a tile still blank.
|
||||
if changed(Topic::Thumbnails) {
|
||||
refresh_file_thumbnails(ui);
|
||||
}
|
||||
let page = ui.stack.visible_child_name();
|
||||
// A timeline out of sight is read again when the user comes back to it;
|
||||
// one on screen is left alone (below).
|
||||
|
||||
@@ -40,6 +40,8 @@ use widgets::crumb::*;
|
||||
use widgets::details::*;
|
||||
use widgets::file_list::*;
|
||||
use widgets::menu::*;
|
||||
use widgets::photo_drag::*;
|
||||
use widgets::player::*;
|
||||
use widgets::scrubber::*;
|
||||
use widgets::share_dialog::*;
|
||||
use widgets::thumbnails::*;
|
||||
@@ -525,6 +527,7 @@ fn build_window(app: &adw::Application) {
|
||||
jump: Cell::new(None),
|
||||
burst: Cell::new(false),
|
||||
import_banner: gallery_widgets.import_banner.clone(),
|
||||
upload_bar: gallery_widgets.upload_bar.clone(),
|
||||
upload: gallery_widgets.upload.clone(),
|
||||
empty_actions: gallery_widgets.empty_actions.clone(),
|
||||
title: gallery_widgets.title.clone(),
|
||||
@@ -1517,7 +1520,7 @@ fn debug_info(ui: &Rc<Ui>) -> String {
|
||||
/// The keyboard-shortcut cheatsheet behind the menu entry, listing exactly what
|
||||
/// [`install_shortcuts`], the Photos page and the photo viewer bind.
|
||||
fn show_shortcuts(window: &adw::ApplicationWindow) {
|
||||
const GROUPS: [(&str, &[(&str, &str)]); 4] = [
|
||||
const GROUPS: [(&str, &[(&str, &str)]); 5] = [
|
||||
(
|
||||
gettext_noop("General"),
|
||||
&[
|
||||
@@ -1567,6 +1570,15 @@ fn show_shortcuts(window: &adw::ApplicationWindow) {
|
||||
("Escape <Alt>Left", gettext_noop("Close")),
|
||||
],
|
||||
),
|
||||
(
|
||||
gettext_noop("Video Player"),
|
||||
&[
|
||||
("space k", gettext_noop("Play / pause")),
|
||||
("j l", gettext_noop("Back / forward 10 seconds")),
|
||||
("m", gettext_noop("Mute")),
|
||||
("f", gettext_noop("Fullscreen")),
|
||||
],
|
||||
),
|
||||
];
|
||||
let groups: Vec<_> = GROUPS
|
||||
.iter()
|
||||
@@ -2036,6 +2048,13 @@ mod tests {
|
||||
assert!(next_group_member(&[], "jpeg").is_none());
|
||||
}
|
||||
|
||||
/// The upload bar counts the upload under way, not the ones finished.
|
||||
#[test]
|
||||
fn the_upload_bar_counts_the_upload_under_way() {
|
||||
assert_eq!(upload_progress_text(0, 12), "Uploading 1 of 12 items…");
|
||||
assert_eq!(upload_progress_text(11, 12), "Uploading 12 of 12 items…");
|
||||
}
|
||||
|
||||
/// The pill names what kind of shot it switches through, and lights up
|
||||
/// on the file that is not the shot's main one.
|
||||
#[test]
|
||||
|
||||
@@ -898,27 +898,15 @@ fn show_location_column(ui: &Rc<Ui>, visible: bool) {
|
||||
pub(crate) fn entry_context_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
|
||||
let mut menu = ActionMenu::new();
|
||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||
// Open does what a double-click does: a video or a song plays in the app,
|
||||
// and Open With is the way to another player.
|
||||
menu.item(&pgettext("verb", "Open"), move || {
|
||||
// Open always means "download a local copy and hand off", even for a
|
||||
// media file — `activate_entry` would otherwise stream it.
|
||||
if is_streamable_media_entry(&entry_c) {
|
||||
download_and_open(&ui_c, &entry_c);
|
||||
} else {
|
||||
activate_entry(&ui_c, &entry_c);
|
||||
}
|
||||
activate_entry(&ui_c, &entry_c)
|
||||
});
|
||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||
menu.item(&gettext("Open With…"), move || {
|
||||
open_entry_with(&ui_c, &entry_c)
|
||||
});
|
||||
// Play streams from the mount, no download; Open above still fetches a
|
||||
// local copy for anyone who wants one.
|
||||
if is_streamable_media_entry(entry) {
|
||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||
menu.item(&pgettext("verb", "Play"), move || {
|
||||
stream_entry(&ui_c, &entry_c)
|
||||
});
|
||||
}
|
||||
menu.section();
|
||||
|
||||
// Kept through a pinned folder above it, the entry has no pin of its own
|
||||
@@ -1107,38 +1095,8 @@ pub(crate) fn entry_at(model: Option<&impl IsA<gio::ListModel>>, pos: u32) -> Op
|
||||
Some(entry)
|
||||
}
|
||||
|
||||
/// Whether an entry can be opened through FUSE instead of fully materialized.
|
||||
pub(crate) fn is_streamable_media_entry(entry: &DirEntry) -> bool {
|
||||
drive_activation(&entry.name, entry.is_dir) == DriveActivation::MountedMedia
|
||||
}
|
||||
|
||||
/// Stream media straight from the mount, no download. Drive folders *are* part
|
||||
/// of the FUSE mount, so a player pointed at `<mountpoint>/<rel>` reads the file
|
||||
/// through [`Core::read_range_remote`] — 4 MB blocks fetched on demand as it
|
||||
/// seeks and buffers — instead of waiting for the whole file to land like
|
||||
/// [`Request::OpenFile`] does. This is the point of the feature: a 2 GB HEVC
|
||||
/// `.mkv` starts playing in seconds.
|
||||
pub(crate) fn stream_entry(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
let rel = entry_rel(ui, entry);
|
||||
let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config());
|
||||
let abs = mounted_path(&mountpoint, &rel);
|
||||
let Some(path) = abs.to_str() else {
|
||||
toast_error(
|
||||
ui,
|
||||
&gettext("Couldn't play media"),
|
||||
&gettext("The file path isn't valid UTF-8."),
|
||||
);
|
||||
return;
|
||||
};
|
||||
toast(
|
||||
ui,
|
||||
&gettext_f("Streaming “{name}”…", &[("name", &entry.name)]),
|
||||
);
|
||||
play_external(path);
|
||||
}
|
||||
|
||||
/// Open an entry the Nautilus way: folders descend, media streams from the mount,
|
||||
/// other files download-and-open.
|
||||
/// Open an entry the Nautilus way: folders descend, media plays in the app,
|
||||
/// streamed from the mount, other files download-and-open.
|
||||
pub(crate) fn activate_entry(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
let rel = entry_rel(ui, entry);
|
||||
if entry.is_dir {
|
||||
@@ -1151,16 +1109,16 @@ pub(crate) fn activate_entry(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
} else if drive_activation(&entry.name, entry.is_dir) == DriveActivation::MountedMedia {
|
||||
// Media streams rather than downloads: that is exactly the "play it,
|
||||
// don't fetch the whole thing" behaviour this is for.
|
||||
stream_entry(ui, entry);
|
||||
open_media_player(ui, entry);
|
||||
} else {
|
||||
download_and_open(ui, entry);
|
||||
}
|
||||
}
|
||||
|
||||
/// Hand a file to the user's default application: through the mount when it is
|
||||
/// there, otherwise downloaded into the cache first. The open path behind both
|
||||
/// a plain double-click and the context menu's "Open" (including for a video,
|
||||
/// when the user wants their default application rather than the player).
|
||||
/// there, otherwise downloaded into the cache first. The open path behind a
|
||||
/// double-click and the context menu's "Open" for anything but a folder or
|
||||
/// media, and for media the mount doesn't show.
|
||||
///
|
||||
/// The mount comes first because the cache blob is a *copy* keyed by content
|
||||
/// hash — an application saving into it writes where Drive will never look, and
|
||||
|
||||
@@ -279,6 +279,7 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
|
||||
clear_info(viewer);
|
||||
stop_video(viewer);
|
||||
viewer.media.set_visible_child_name("photo");
|
||||
sync_strip(viewer);
|
||||
reset_zoom(viewer);
|
||||
// A slideshow waits for this photo to land before it starts counting.
|
||||
disarm_slide(viewer);
|
||||
@@ -356,6 +357,7 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
|
||||
None => {
|
||||
viewer.video.set_filename(Some(&path));
|
||||
viewer.media.set_visible_child_name("video");
|
||||
sync_strip(&viewer);
|
||||
*viewer.path.borrow_mut() = Some(path.clone());
|
||||
show_info(&viewer, size, ExifInfo::default());
|
||||
}
|
||||
@@ -396,7 +398,7 @@ fn start_slideshow(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
||||
viewer.slide_fullscreened.set(true);
|
||||
}
|
||||
hide_chrome(viewer);
|
||||
viewer.strip_reveal.set_visible(false);
|
||||
sync_strip(viewer);
|
||||
if !viewer.loading.get() {
|
||||
arm_slide(ui, viewer);
|
||||
}
|
||||
@@ -405,9 +407,7 @@ fn start_slideshow(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
||||
/// Stop the slideshow, leaving fullscreen only if the slideshow entered it.
|
||||
fn stop_slideshow(viewer: &Rc<Viewer>) {
|
||||
viewer.slideshow.set(false);
|
||||
viewer
|
||||
.strip_reveal
|
||||
.set_visible(viewer.strip_selection.n_items() > 1);
|
||||
sync_strip(viewer);
|
||||
disarm_slide(viewer);
|
||||
if viewer.slide_fullscreened.replace(false) {
|
||||
viewer.window.unfullscreen();
|
||||
@@ -1114,10 +1114,18 @@ fn show_photo_position(ui: &Rc<Ui>, viewer: &Rc<Viewer>, index: u32, capture_tim
|
||||
None => position,
|
||||
};
|
||||
viewer.counter.set_label(&line);
|
||||
sync_strip(viewer);
|
||||
mark_in_strip(viewer, index);
|
||||
}
|
||||
|
||||
/// Show the strip of thumbnails when there is more than one photo to pick
|
||||
/// from. Not in a slideshow, and not over a video, where it would cover the
|
||||
/// player's seek bar.
|
||||
fn sync_strip(viewer: &Rc<Viewer>) {
|
||||
let video = viewer.media.visible_child_name().as_deref() == Some("video");
|
||||
viewer
|
||||
.strip_reveal
|
||||
.set_visible(loaded > 1 && !viewer.slideshow.get());
|
||||
mark_in_strip(viewer, index);
|
||||
.set_visible(viewer.strip_selection.n_items() > 1 && !viewer.slideshow.get() && !video);
|
||||
}
|
||||
|
||||
/// Mark the photo at `index` in the strip and bring it into view.
|
||||
@@ -1239,6 +1247,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
wire_player(&video, &window);
|
||||
let media = gtk4::Stack::new();
|
||||
media.add_named(&scroller, Some("photo"));
|
||||
media.add_named(&video, Some("video"));
|
||||
@@ -1335,6 +1344,9 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
|
||||
// Everything used less often than the favorite and the details sits in
|
||||
// one menu, named in words rather than in icons.
|
||||
let more_btn = action("view-more-symbolic", &gettext("More"));
|
||||
let fullscreen_btn = fullscreen_button(&window);
|
||||
fullscreen_btn.add_css_class("flat");
|
||||
fullscreen_btn.add_css_class("viewer-action-btn");
|
||||
let close_btn = action("window-close-symbolic", &gettext("Close (Esc)"));
|
||||
close_btn.add_css_class("viewer-close-btn");
|
||||
|
||||
@@ -1344,6 +1356,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
|
||||
top_bar.append(&group_btn);
|
||||
top_bar.append(&favorite_btn);
|
||||
top_bar.append(&info_toggle);
|
||||
top_bar.append(&fullscreen_btn);
|
||||
top_bar.append(&more_btn);
|
||||
top_bar.append(&close_btn);
|
||||
let top_reveal = fade(top_bar.upcast_ref(), gtk4::Align::Fill, gtk4::Align::Start);
|
||||
@@ -1660,7 +1673,6 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
|
||||
}
|
||||
});
|
||||
let viewer_items = Rc::downgrade(&viewer);
|
||||
let ui_items = ui.clone();
|
||||
let items_handler = ui
|
||||
.gallery
|
||||
.model
|
||||
@@ -1671,9 +1683,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
|
||||
if let Some(index) = find_photo_index(model, &viewer.uid.borrow()) {
|
||||
viewer.strip_selection.set_selected(index);
|
||||
}
|
||||
viewer
|
||||
.strip_reveal
|
||||
.set_visible(ui_items.gallery.model.n_items() > 1 && !viewer.slideshow.get());
|
||||
sync_strip(&viewer);
|
||||
});
|
||||
|
||||
// The one strong handle on the viewer. Every handler holds a weak one: a
|
||||
@@ -1873,6 +1883,12 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
|
||||
let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
|
||||
let alt = state.contains(gtk4::gdk::ModifierType::ALT_MASK);
|
||||
let playing_video = viewer_key.media.visible_child_name().as_deref() == Some("video");
|
||||
if playing_video
|
||||
&& let Some(name) = key.name()
|
||||
&& player_key(&viewer_key.video, &name, state)
|
||||
{
|
||||
return glib::Propagation::Stop;
|
||||
}
|
||||
match key.name().as_deref() {
|
||||
Some("space") if playing_video => {
|
||||
if let Some(stream) = viewer_key.video.media_stream() {
|
||||
@@ -1896,13 +1912,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Windo
|
||||
Some("i") => viewer_key
|
||||
.info_toggle
|
||||
.set_active(!viewer_key.info_toggle.is_active()),
|
||||
Some("f" | "F11") => {
|
||||
if w_key.is_fullscreen() {
|
||||
w_key.unfullscreen();
|
||||
} else {
|
||||
w_key.fullscreen();
|
||||
}
|
||||
}
|
||||
Some("f" | "F11") => toggle_fullscreen(&w_key),
|
||||
Some("Delete" | "KP_Delete") => trash_shown_photo(&ui_key, &viewer_key),
|
||||
Some("Escape" | "q") => w_key.close(),
|
||||
Some("w") if ctrl => w_key.close(),
|
||||
@@ -1934,6 +1944,12 @@ fn open_viewer_menu(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
||||
};
|
||||
let mut menu = ActionMenu::new();
|
||||
menu.item(&gettext("Slideshow"), with_viewer(toggle_slideshow, ui));
|
||||
if viewer.media.visible_child_name().as_deref() == Some("video") {
|
||||
let video = viewer.video.clone();
|
||||
menu.toggle(&gettext("Repeat"), video.is_loop(), move |on| {
|
||||
video.set_loop(on)
|
||||
});
|
||||
}
|
||||
menu.section();
|
||||
// These need the full-size file, which may still be downloading.
|
||||
if viewer.path.borrow().is_some() {
|
||||
|
||||
@@ -37,6 +37,9 @@ pub(crate) struct GalleryState {
|
||||
pub(crate) burst: Cell<bool>,
|
||||
/// Says a Google Photos import is running, with a way to its page.
|
||||
pub(crate) import_banner: adw::Banner,
|
||||
/// The bar that counts uploads from this computer through, under the
|
||||
/// header while any run.
|
||||
pub(crate) upload_bar: UploadBar,
|
||||
pub(crate) upload: adw::SplitButton,
|
||||
/// Upload / Import offered on the *empty timeline* status page, so a fresh
|
||||
/// account is a place to start rather than a dead end. Hidden on every other
|
||||
@@ -478,6 +481,9 @@ pub(crate) struct GalleryWidgets {
|
||||
pub(crate) scrubber: Scrubber,
|
||||
/// Says a Google Photos import is running, with a way to its page.
|
||||
pub(crate) import_banner: adw::Banner,
|
||||
/// The bar that counts uploads from this computer through, under the
|
||||
/// header while any run.
|
||||
pub(crate) upload_bar: UploadBar,
|
||||
pub(crate) list: gtk4::ListView,
|
||||
pub(crate) scroll: gtk4::ScrolledWindow,
|
||||
pub(crate) retry: gtk4::Button,
|
||||
@@ -903,6 +909,8 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
.action_name("win.show-import")
|
||||
.build();
|
||||
|
||||
let upload_bar = UploadBar::new();
|
||||
|
||||
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||
inner.set_margin_top(12);
|
||||
inner.set_margin_bottom(12);
|
||||
@@ -913,6 +921,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
|
||||
let body = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
||||
body.append(&import_banner);
|
||||
body.append(&upload_bar.revealer);
|
||||
body.append(&inner);
|
||||
// The title slot holds the view switch on the page itself, the album's or
|
||||
// place's name inside one, and the count in selection mode (see
|
||||
@@ -943,6 +952,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
pager,
|
||||
scrubber,
|
||||
import_banner,
|
||||
upload_bar,
|
||||
list,
|
||||
scroll,
|
||||
retry,
|
||||
@@ -1384,6 +1394,13 @@ pub(crate) fn wire_gallery(
|
||||
sync_float_heading(&ui_scroll);
|
||||
ui_scroll.gallery.scrubber.flash();
|
||||
});
|
||||
// Zooming out or making the window taller can leave every loaded photo on
|
||||
// one screen. Nothing scrolls then, so the scrolling above never asks for
|
||||
// the next page: ask whenever the content shrinks under the viewport.
|
||||
let ui_fill = ui.clone();
|
||||
scroll
|
||||
.vadjustment()
|
||||
.connect_changed(move |_| fill_viewport(&ui_fill));
|
||||
wire_scrubber(ui);
|
||||
wire_drag_select(ui);
|
||||
|
||||
@@ -1513,9 +1530,11 @@ pub(crate) fn wire_gallery(
|
||||
gtk4::gdk::DragAction::COPY,
|
||||
);
|
||||
let ui_drop = ui.clone();
|
||||
drop.connect_accept(move |_, _| {
|
||||
drop.connect_accept(move |_, drop| {
|
||||
let gallery = &ui_drop.gallery;
|
||||
!gallery.in_collection()
|
||||
// Photos dragged out of the timeline are already in it.
|
||||
drop.drag().is_none()
|
||||
&& !gallery.in_collection()
|
||||
&& gallery.view_switch.active() == VIEW_TIMELINE
|
||||
&& gallery.library.get()
|
||||
&& *ui_drop.mounted.borrow()
|
||||
@@ -1913,6 +1932,86 @@ pub(crate) fn copy_without_overwrite(
|
||||
unreachable!("some numbered name is free")
|
||||
}
|
||||
|
||||
/// How far the uploads from this computer have come: a count and a bar, so a
|
||||
/// drop of a few hundred photos shows it is moving. Batches started while one
|
||||
/// runs join its count.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct UploadBar {
|
||||
pub(crate) revealer: gtk4::Revealer,
|
||||
label: gtk4::Label,
|
||||
progress: gtk4::ProgressBar,
|
||||
/// Uploads finished, and uploads asked for, since the bar last emptied.
|
||||
count: Rc<Cell<(usize, usize)>>,
|
||||
}
|
||||
|
||||
impl UploadBar {
|
||||
fn new() -> Self {
|
||||
let label = gtk4::Label::builder()
|
||||
.halign(gtk4::Align::Start)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||
.build();
|
||||
let progress = gtk4::ProgressBar::builder().hexpand(true).build();
|
||||
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
|
||||
content.set_margin_top(10);
|
||||
content.set_margin_bottom(4);
|
||||
content.set_margin_start(16);
|
||||
content.set_margin_end(16);
|
||||
content.append(&label);
|
||||
content.append(&progress);
|
||||
let revealer = gtk4::Revealer::builder()
|
||||
.transition_type(gtk4::RevealerTransitionType::SlideDown)
|
||||
.child(&content)
|
||||
.build();
|
||||
Self {
|
||||
revealer,
|
||||
label,
|
||||
progress,
|
||||
count: Rc::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Count `n` more uploads in.
|
||||
pub(crate) fn add(&self, n: usize) {
|
||||
let (done, total) = self.count.get();
|
||||
self.count.set((done, total + n));
|
||||
self.paint();
|
||||
}
|
||||
|
||||
/// Count one upload as finished, whether or not it went through.
|
||||
pub(crate) fn finish(&self) {
|
||||
let (done, total) = self.count.get();
|
||||
let done = (done + 1).min(total);
|
||||
if done == total {
|
||||
self.count.set((0, 0));
|
||||
self.revealer.set_reveal_child(false);
|
||||
return;
|
||||
}
|
||||
self.count.set((done, total));
|
||||
self.paint();
|
||||
}
|
||||
|
||||
fn paint(&self) {
|
||||
let (done, total) = self.count.get();
|
||||
if total == 0 {
|
||||
return;
|
||||
}
|
||||
self.label.set_label(&upload_progress_text(done, total));
|
||||
self.progress.set_fraction(done as f64 / total as f64);
|
||||
self.revealer.set_reveal_child(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// "Uploading 3 of 12 items…": the upload under way is number `done + 1`.
|
||||
pub(crate) fn upload_progress_text(done: usize, total: usize) -> String {
|
||||
// Translators: progress above the photo timeline while photos upload; {position} is the upload under way, {n} how many there are.
|
||||
ngettext_f(
|
||||
"Uploading {position} of {n} item…",
|
||||
"Uploading {position} of {n} items…",
|
||||
total as u64,
|
||||
&[("position", &thousands(done + 1))],
|
||||
)
|
||||
}
|
||||
|
||||
/// Upload photos and videos from this computer into the timeline, one after
|
||||
/// the other, then reload it once.
|
||||
pub(crate) fn upload_media(ui: &Rc<Ui>, paths: Vec<PathBuf>) {
|
||||
@@ -1927,15 +2026,7 @@ pub(crate) fn upload_media(ui: &Rc<Ui>, paths: Vec<PathBuf>) {
|
||||
let total = uploads.len();
|
||||
ui.busy_begin();
|
||||
if total > 1 {
|
||||
toast(
|
||||
ui,
|
||||
&ngettext_f(
|
||||
"Uploading {n} item…",
|
||||
"Uploading {n} items…",
|
||||
total as u64,
|
||||
&[],
|
||||
),
|
||||
);
|
||||
ui.gallery.upload_bar.add(total);
|
||||
}
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
@@ -1973,6 +2064,9 @@ pub(crate) fn upload_media(ui: &Rc<Ui>, paths: Vec<PathBuf>) {
|
||||
))
|
||||
}
|
||||
}
|
||||
if total > 1 {
|
||||
ui.gallery.upload_bar.finish();
|
||||
}
|
||||
}
|
||||
ui.busy_end();
|
||||
if uploaded > 0 {
|
||||
@@ -2381,6 +2475,23 @@ impl TileWidget {
|
||||
});
|
||||
button.add_controller(context);
|
||||
|
||||
// A picked photo drags out, with the rest of the selection, into a
|
||||
// file manager or any app that takes files. A drag from anywhere else
|
||||
// selects instead (see `wire_drag_select`).
|
||||
let drag_out = gtk4::DragSource::new();
|
||||
drag_out.set_actions(gtk4::gdk::DragAction::COPY);
|
||||
let ui_drag = ui.clone();
|
||||
let drag_uid = uid.clone();
|
||||
drag_out.connect_prepare(move |source, x, y| {
|
||||
let photos = dragged_photos(&ui_drag, &drag_uid()?)?;
|
||||
if let Some(tile) = source.widget() {
|
||||
let icon = gtk4::WidgetPaintable::new(Some(&tile));
|
||||
source.set_icon(Some(&icon), x as i32, y as i32);
|
||||
}
|
||||
Some(PhotoDrag::new(ui_drag.dirs.control_socket(), photos).upcast())
|
||||
});
|
||||
button.add_controller(drag_out);
|
||||
|
||||
// A tile opens in the in-app lightbox, which plays a video in place.
|
||||
// In selection mode a tile picks instead of opening: the lightbox is
|
||||
// one Escape away.
|
||||
@@ -2478,6 +2589,23 @@ impl TileWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// The photos a drag that starts on `uid` carries out: the whole selection,
|
||||
/// in timeline order, as uid and name. `None` unless `uid` is picked.
|
||||
fn dragged_photos(ui: &Rc<Ui>, uid: &str) -> Option<Vec<(String, Option<String>)>> {
|
||||
let selected = ui.gallery.selected.borrow();
|
||||
if !ui.gallery.selecting.get() || !selected.contains(uid) {
|
||||
return None;
|
||||
}
|
||||
let model = &ui.gallery.model;
|
||||
let photos: Vec<(String, Option<String>)> = (0..model.n_items())
|
||||
.filter_map(|index| model.item(index).and_downcast::<BoxedAnyObject>())
|
||||
.map(|boxed| boxed.borrow::<PhotoItem>().clone())
|
||||
.filter(|photo| selected.contains(&photo.uid))
|
||||
.map(|photo| (photo.uid, photo.name))
|
||||
.collect();
|
||||
(!photos.is_empty()).then_some(photos)
|
||||
}
|
||||
|
||||
/// Add or remove one CSS class, for widgets that are rebound rather than
|
||||
/// rebuilt.
|
||||
fn set_css_class(widget: &impl IsA<gtk4::Widget>, class: &str, on: bool) {
|
||||
@@ -2556,6 +2684,9 @@ struct DragSelect {
|
||||
pointer: (f64, f64),
|
||||
/// The edge autoscroll, while the pointer is near the top or bottom.
|
||||
scroll: Option<glib::SourceId>,
|
||||
/// The drag started on a picked photo, so it carries the selection out
|
||||
/// of the app rather than selecting.
|
||||
out: bool,
|
||||
}
|
||||
|
||||
/// Dragging across the timeline selects every photo from where the drag
|
||||
@@ -2572,21 +2703,33 @@ fn wire_drag_select(ui: &Rc<Ui>) {
|
||||
let state = Rc::new(RefCell::new(DragSelect::default()));
|
||||
|
||||
let state_begin = state.clone();
|
||||
drag.connect_drag_begin(move |_, x, y| {
|
||||
let ui_begin = ui.clone();
|
||||
drag.connect_drag_begin(move |gesture, x, y| {
|
||||
let mut drag = state_begin.borrow_mut();
|
||||
if let Some(source) = drag.scroll.take() {
|
||||
source.remove();
|
||||
}
|
||||
let out = ui_begin.gallery.selecting.get()
|
||||
&& tile_at(&ui_begin, x, y)
|
||||
.is_some_and(|uid| ui_begin.gallery.selected.borrow().contains(&uid));
|
||||
*drag = DragSelect {
|
||||
start: (x, y),
|
||||
pointer: (x, y),
|
||||
out,
|
||||
..DragSelect::default()
|
||||
};
|
||||
// Left to the tile's own drag source.
|
||||
if out {
|
||||
gesture.set_state(gtk4::EventSequenceState::Denied);
|
||||
}
|
||||
});
|
||||
|
||||
let ui_update = ui.clone();
|
||||
let state_update = state.clone();
|
||||
drag.connect_drag_update(move |gesture, dx, dy| {
|
||||
if state_update.borrow().out {
|
||||
return;
|
||||
}
|
||||
let (sx, sy) = state_update.borrow().start;
|
||||
state_update.borrow_mut().pointer = (sx + dx, sy + dy);
|
||||
if !state_update.borrow().active {
|
||||
@@ -3954,17 +4097,32 @@ fn continue_jump(ui: &Rc<Ui>) {
|
||||
/// Load the next page when the timeline does not fill the window yet: with no
|
||||
/// scrollbar there is no scrolling to trigger it.
|
||||
fn fill_viewport(ui: &Rc<Ui>) {
|
||||
if !ui.gallery.has_more.get() || ui.gallery.loading.get() {
|
||||
return;
|
||||
}
|
||||
let ui = ui.clone();
|
||||
glib::idle_add_local_once(move || {
|
||||
let Some(adj) = ui.gallery.list.vadjustment() else {
|
||||
return;
|
||||
};
|
||||
if ui.gallery.has_more.get() && adj.upper() <= adj.page_size() + 1.0 {
|
||||
let timeline = ui.gallery.content.visible_child_name().as_deref() == Some("timeline");
|
||||
if timeline
|
||||
&& ui.gallery.has_more.get()
|
||||
&& adj.page_size() > 0.0
|
||||
&& viewport_unfilled(adj.upper(), adj.page_size())
|
||||
{
|
||||
load_gallery(&ui, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether content `upper` px tall leaves a viewport `page_size` px tall short
|
||||
/// of a screen and a half to scroll through, the margin the scrolling pager
|
||||
/// keeps. Below it the pager would never fire.
|
||||
pub(crate) fn viewport_unfilled(upper: f64, page_size: f64) -> bool {
|
||||
upper <= page_size * 2.5 + 1.0
|
||||
}
|
||||
|
||||
/// Select every photo loaded so far (Ctrl+A).
|
||||
pub(crate) fn select_all_photos(ui: &Rc<Ui>) {
|
||||
set_selection_mode(ui, true);
|
||||
@@ -4344,24 +4502,13 @@ pub(crate) fn gallery_unreachable(ui: &Rc<Ui>) {
|
||||
service_unreachable(ui, "gallery", gallery_status, |ui| load_gallery(ui, false));
|
||||
}
|
||||
|
||||
/// Play a video with an external player. Prefers `mpv` — it sniffs the container
|
||||
/// from the bytes, so the cache's extensionless blob plays fine, and it is the
|
||||
/// right tool for the HEVC `.mkv`s this is aimed at — and falls back to the
|
||||
/// user's default handler when mpv isn't installed.
|
||||
pub(crate) fn play_external(path: &str) {
|
||||
match Command::new("mpv").arg(path).spawn() {
|
||||
Ok(child) => pdfs_core::opener::reap(child),
|
||||
Err(_) => open_path(path),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
Grouping, ROW_DEFAULT, ROW_MAX, ROW_MIN, ROW_STEP, TILE_GAP, TILE_RATIO_MAX,
|
||||
Grouping, PHOTOS_PAGE, ROW_DEFAULT, ROW_MAX, ROW_MIN, ROW_STEP, TILE_GAP, TILE_RATIO_MAX,
|
||||
TILE_RATIO_MIN, active_filters, copy_without_overwrite, empty_timeline_text, grouping_for,
|
||||
memory_years, month_index, month_range, plan_grid, plan_justified, section_heading,
|
||||
tile_ratio,
|
||||
tile_ratio, viewport_unfilled,
|
||||
};
|
||||
use pdfs_core::control::{PhotoItem, PhotoKind, PhotoMonth};
|
||||
|
||||
@@ -4484,6 +4631,18 @@ mod tests {
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
/// A first page zoomed all the way out fits on one screen, with nothing
|
||||
/// to scroll: the next page has to load without a scroll asking for it.
|
||||
#[test]
|
||||
fn a_page_zoomed_out_onto_one_screen_asks_for_more() {
|
||||
let (columns, size) = plan_grid(1580, ROW_MIN);
|
||||
let rows = PHOTOS_PAGE.div_ceil(columns) as i32;
|
||||
let height = f64::from(rows * (size + TILE_GAP) + 40);
|
||||
assert!(viewport_unfilled(height, 880.0));
|
||||
assert!(viewport_unfilled(880.0, 880.0));
|
||||
assert!(!viewport_unfilled(880.0 * 3.0, 880.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zooming_in_puts_fewer_tiles_in_a_row() {
|
||||
assert!(plan_grid(1920, ROW_MIN).0 > plan_grid(1920, ROW_DEFAULT).0);
|
||||
|
||||
@@ -3,6 +3,8 @@ pub(crate) mod crumb;
|
||||
pub(crate) mod details;
|
||||
pub(crate) mod file_list;
|
||||
pub(crate) mod menu;
|
||||
pub(crate) mod photo_drag;
|
||||
pub(crate) mod player;
|
||||
pub(crate) mod scrubber;
|
||||
pub(crate) mod share_dialog;
|
||||
pub(crate) mod thumbnails;
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
//! Dragging photos out of the timeline into a file manager or another app.
|
||||
//!
|
||||
//! A photo is not a file anywhere the receiver can read until it has been
|
||||
//! downloaded, and a drag has to start the moment the pointer moves. So the
|
||||
//! drag offers a list of files without having them: [`PhotoDrag`] downloads
|
||||
//! the photos only when the receiver asks for the list, which it does once the
|
||||
//! photos are dropped on it, and then hands over copies under their own names.
|
||||
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use gtk4::gdk;
|
||||
use gtk4::gdk::subclass::prelude::*;
|
||||
|
||||
use crate::*;
|
||||
|
||||
/// The one format a drag of photos offers. GTK turns it into a file list for
|
||||
/// a receiver in this app, and every file manager reads it.
|
||||
const URI_LIST: &str = "text/uri-list";
|
||||
|
||||
/// How long the copies handed to a receiver are kept. The receiver copies
|
||||
/// them on drop, so a day is far more than it needs.
|
||||
const KEEP_DRAGGED: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
|
||||
mod imp {
|
||||
use super::*;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct PhotoDrag {
|
||||
/// The photos dragged, as uid and Drive name.
|
||||
pub(super) photos: RefCell<Vec<(String, Option<String>)>>,
|
||||
pub(super) socket: RefCell<PathBuf>,
|
||||
}
|
||||
|
||||
#[glib::object_subclass]
|
||||
impl ObjectSubclass for PhotoDrag {
|
||||
const NAME: &'static str = "PdfsPhotoDrag";
|
||||
type Type = super::PhotoDrag;
|
||||
type ParentType = gdk::ContentProvider;
|
||||
}
|
||||
|
||||
impl ObjectImpl for PhotoDrag {}
|
||||
|
||||
impl ContentProviderImpl for PhotoDrag {
|
||||
fn formats(&self) -> gdk::ContentFormats {
|
||||
gdk::ContentFormats::new(&[URI_LIST])
|
||||
}
|
||||
|
||||
fn write_mime_type_future(
|
||||
&self,
|
||||
mime_type: &str,
|
||||
stream: &gio::OutputStream,
|
||||
io_priority: glib::Priority,
|
||||
) -> Pin<Box<dyn Future<Output = Result<(), glib::Error>> + 'static>> {
|
||||
let photos = self.photos.borrow().clone();
|
||||
let socket = self.socket.borrow().clone();
|
||||
let stream = stream.clone();
|
||||
let wanted = mime_type == URI_LIST;
|
||||
Box::pin(async move {
|
||||
if !wanted {
|
||||
return Err(glib::Error::new(
|
||||
gio::IOErrorEnum::NotSupported,
|
||||
"photos are offered as a list of files only",
|
||||
));
|
||||
}
|
||||
let files = fetch_dragged(socket, photos).await?;
|
||||
let list: String = files
|
||||
.iter()
|
||||
.map(|path| format!("{}\r\n", gio::File::for_path(path).uri()))
|
||||
.collect();
|
||||
stream
|
||||
.write_all_future(list.into_bytes(), io_priority)
|
||||
.await
|
||||
.map_err(|(_, e)| e)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
glib::wrapper! {
|
||||
/// The content of a drag of photos: their files, fetched when dropped.
|
||||
pub struct PhotoDrag(ObjectSubclass<imp::PhotoDrag>)
|
||||
@extends gdk::ContentProvider;
|
||||
}
|
||||
|
||||
impl PhotoDrag {
|
||||
pub(crate) fn new(socket: PathBuf, photos: Vec<(String, Option<String>)>) -> Self {
|
||||
let drag: Self = glib::Object::new();
|
||||
*drag.imp().photos.borrow_mut() = photos;
|
||||
*drag.imp().socket.borrow_mut() = socket;
|
||||
drag
|
||||
}
|
||||
}
|
||||
|
||||
/// Download each dragged photo and put a copy under its Drive name where the
|
||||
/// receiver can read it. A photo that fails is left out; only when every one
|
||||
/// fails does the drop fail.
|
||||
async fn fetch_dragged(
|
||||
socket: PathBuf,
|
||||
photos: Vec<(String, Option<String>)>,
|
||||
) -> Result<Vec<PathBuf>, glib::Error> {
|
||||
let base = glib::user_cache_dir().join("pdfs-drag");
|
||||
let mut files = Vec::new();
|
||||
let mut last_error = None;
|
||||
for (uid, name) in photos {
|
||||
let rx = spawn_request(socket.clone(), Request::OpenPhoto { uid: uid.clone() });
|
||||
let source = match rx.recv().await {
|
||||
Ok(Ok(Response::FilePath { path })) => path,
|
||||
Ok(Ok(Response::Error { message, .. })) => {
|
||||
last_error = Some(message);
|
||||
continue;
|
||||
}
|
||||
_ => {
|
||||
last_error = Some("the Proton Drive service didn't respond".to_string());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let base = base.clone();
|
||||
let copied = gio::spawn_blocking(move || {
|
||||
prune_dragged(&base, SystemTime::now());
|
||||
let name = name.unwrap_or_else(|| "photo".to_string());
|
||||
hand_over(&base, Path::new(&source), &uid, &name)
|
||||
})
|
||||
.await;
|
||||
match copied {
|
||||
Ok(Ok(path)) => files.push(path),
|
||||
Ok(Err(e)) => last_error = Some(e.to_string()),
|
||||
Err(_) => last_error = Some("the copy was interrupted".to_string()),
|
||||
}
|
||||
}
|
||||
if files.is_empty() {
|
||||
let message = last_error.unwrap_or_else(|| "nothing to drop".to_string());
|
||||
tracing::warn!("dragging photos out failed: {message}");
|
||||
return Err(glib::Error::new(gio::IOErrorEnum::Failed, &message));
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// Put the cached photo `source` at `base/<uid>/<name>`, where a receiver
|
||||
/// copies it from.
|
||||
///
|
||||
/// A hard link when the cache is on the same file system, so a large video is
|
||||
/// not copied twice; a copy otherwise. Not a symlink: a file manager copies a
|
||||
/// symlink as a link, which would leave the user a link into the cache.
|
||||
pub(crate) fn hand_over(
|
||||
base: &Path,
|
||||
source: &Path,
|
||||
uid: &str,
|
||||
name: &str,
|
||||
) -> std::io::Result<PathBuf> {
|
||||
let file_name = Path::new(name)
|
||||
.file_name()
|
||||
.ok_or_else(|| std::io::Error::other("the photo has no usable name"))?;
|
||||
let dir = base.join(uid.replace('/', "_"));
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let dest = dir.join(file_name);
|
||||
let _ = std::fs::remove_file(&dest);
|
||||
if std::fs::hard_link(source, &dest).is_err() {
|
||||
std::fs::copy(source, &dest)?;
|
||||
}
|
||||
Ok(dest)
|
||||
}
|
||||
|
||||
/// Remove the copies handed over more than [`KEEP_DRAGGED`] before `now`.
|
||||
pub(crate) fn prune_dragged(base: &Path, now: SystemTime) {
|
||||
let Ok(entries) = std::fs::read_dir(base) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let old = entry
|
||||
.metadata()
|
||||
.and_then(|meta| meta.modified())
|
||||
.is_ok_and(|modified| {
|
||||
now.duration_since(modified)
|
||||
.is_ok_and(|age| age > KEEP_DRAGGED)
|
||||
});
|
||||
if old {
|
||||
let _ = std::fs::remove_dir_all(entry.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("pdfs-drag-test-{}-{name}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dragged_photo_is_handed_over_under_its_own_name() {
|
||||
let dir = scratch("name");
|
||||
let source = dir.join("0f3a9c");
|
||||
std::fs::write(&source, b"jpeg bytes").unwrap();
|
||||
let base = dir.join("out");
|
||||
|
||||
let path = hand_over(&base, &source, "vol~link", "IMG_0001.JPG").unwrap();
|
||||
assert_eq!(path, base.join("vol~link").join("IMG_0001.JPG"));
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"jpeg bytes");
|
||||
assert!(!path.is_symlink());
|
||||
|
||||
// Dragged again, it replaces the copy rather than failing on it.
|
||||
std::fs::write(&source, b"new bytes").unwrap();
|
||||
let again = hand_over(&base, &source, "vol~link", "IMG_0001.JPG").unwrap();
|
||||
assert_eq!(std::fs::read(again).unwrap(), b"new bytes");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_cannot_climb_out_of_its_folder() {
|
||||
let dir = scratch("climb");
|
||||
let source = dir.join("blob");
|
||||
std::fs::write(&source, b"x").unwrap();
|
||||
let base = dir.join("out");
|
||||
let path = hand_over(&base, &source, "u", "../../escape.jpg").unwrap();
|
||||
assert_eq!(path, base.join("u").join("escape.jpg"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_old_handovers_are_pruned() {
|
||||
let dir = scratch("prune");
|
||||
std::fs::create_dir_all(dir.join("fresh")).unwrap();
|
||||
prune_dragged(&dir, SystemTime::now());
|
||||
assert!(dir.join("fresh").exists());
|
||||
prune_dragged(&dir, SystemTime::now() + KEEP_DRAGGED * 2);
|
||||
assert!(!dir.join("fresh").exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
//! Playing videos and music inside the app.
|
||||
//!
|
||||
//! The pieces both players share live here: the one My files opens a video or
|
||||
//! a song in ([`open_media_player`]) and the Photos lightbox. A GTK
|
||||
//! [`gtk4::Video`] brings play, pause, a seek bar and the volume; this adds
|
||||
//! fullscreen, the keys every video player answers to, a repeat switch and a
|
||||
//! volume that carries over from one video to the next.
|
||||
|
||||
use crate::activation::{mounted_path, present_within};
|
||||
use crate::*;
|
||||
|
||||
/// How far `J` and `L` (or Shift+←/→) jump.
|
||||
const SEEK_LONG: i64 = 10;
|
||||
|
||||
/// How far ← and → jump in the My files player, where they don't move to
|
||||
/// another photo.
|
||||
const SEEK_SHORT: i64 = 5;
|
||||
|
||||
/// How much ↑ and ↓ change the volume in the My files player.
|
||||
const VOLUME_STEP: f64 = 0.1;
|
||||
|
||||
/// The edge of a song's cover in the My files player, in logical pixels.
|
||||
const COVER_SIZE: i32 = 320;
|
||||
|
||||
thread_local! {
|
||||
/// The volume and mute the user last set, applied to the next stream so
|
||||
/// muting one video doesn't leave the next one blaring. Kept for the run
|
||||
/// of the app only.
|
||||
static LEVEL: Cell<Option<(f64, bool)>> = const { Cell::new(None) };
|
||||
}
|
||||
|
||||
/// Where a jump of `delta_secs` from `now` lands, in microseconds, as a
|
||||
/// [`gtk4::MediaStream`] counts. Kept inside the stream; a `duration` of 0
|
||||
/// means the stream doesn't know its length yet, which bounds only the start.
|
||||
pub(crate) fn seek_target(now: i64, duration: i64, delta_secs: i64) -> i64 {
|
||||
let target = now
|
||||
.saturating_add(delta_secs.saturating_mul(1_000_000))
|
||||
.max(0);
|
||||
if duration > 0 {
|
||||
target.min(duration)
|
||||
} else {
|
||||
target
|
||||
}
|
||||
}
|
||||
|
||||
/// Jump the stream `delta_secs` forward, or back when negative.
|
||||
pub(crate) fn seek_by(video: >k4::Video, delta_secs: i64) {
|
||||
if let Some(stream) = video.media_stream()
|
||||
&& stream.is_seekable()
|
||||
{
|
||||
stream.seek(seek_target(
|
||||
stream.timestamp(),
|
||||
stream.duration(),
|
||||
delta_secs,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Play a paused stream, pause a playing one.
|
||||
pub(crate) fn toggle_playing(video: >k4::Video) {
|
||||
if let Some(stream) = video.media_stream() {
|
||||
stream.set_playing(!stream.is_playing());
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_muted(video: >k4::Video) {
|
||||
if let Some(stream) = video.media_stream() {
|
||||
stream.set_muted(!stream.is_muted());
|
||||
}
|
||||
}
|
||||
|
||||
fn change_volume(video: >k4::Video, delta: f64) {
|
||||
if let Some(stream) = video.media_stream() {
|
||||
stream.set_muted(false);
|
||||
stream.set_volume((stream.volume() + delta).clamp(0.0, 1.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Enter fullscreen, or leave it.
|
||||
pub(crate) fn toggle_fullscreen(window: >k4::Window) {
|
||||
if window.is_fullscreen() {
|
||||
window.unfullscreen();
|
||||
} else {
|
||||
window.fullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
/// The keys both players answer to the same way: `K` plays and pauses, `J` and
|
||||
/// `L` (or Shift+← and Shift+→) jump ten seconds, `M` mutes. True when `key`
|
||||
/// was one of them.
|
||||
pub(crate) fn player_key(video: >k4::Video, key: &str, state: gtk4::gdk::ModifierType) -> bool {
|
||||
let shift = state.contains(gtk4::gdk::ModifierType::SHIFT_MASK);
|
||||
let plain = !state
|
||||
.intersects(gtk4::gdk::ModifierType::CONTROL_MASK | gtk4::gdk::ModifierType::ALT_MASK);
|
||||
if !plain {
|
||||
return false;
|
||||
}
|
||||
match key {
|
||||
"k" | "K" => toggle_playing(video),
|
||||
"j" | "J" => seek_by(video, -SEEK_LONG),
|
||||
"l" | "L" => seek_by(video, SEEK_LONG),
|
||||
"Left" if shift => seek_by(video, -SEEK_LONG),
|
||||
"Right" if shift => seek_by(video, SEEK_LONG),
|
||||
"m" | "M" => toggle_muted(video),
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Make `video` carry the volume over between streams and toggle `window`'s
|
||||
/// fullscreen on a double-click.
|
||||
pub(crate) fn wire_player(video: >k4::Video, window: >k4::Window) {
|
||||
video.connect_media_stream_notify(|video| {
|
||||
let Some(stream) = video.media_stream() else {
|
||||
return;
|
||||
};
|
||||
if let Some((volume, muted)) = LEVEL.get() {
|
||||
stream.set_volume(volume);
|
||||
stream.set_muted(muted);
|
||||
}
|
||||
let remember = |stream: >k4::MediaStream| {
|
||||
LEVEL.set(Some((stream.volume(), stream.is_muted())));
|
||||
};
|
||||
stream.connect_volume_notify(remember);
|
||||
stream.connect_muted_notify(remember);
|
||||
});
|
||||
|
||||
// The controls' own buttons and seek bar claim their clicks, so this only
|
||||
// sees a double-click on the picture.
|
||||
let click = gtk4::GestureClick::new();
|
||||
let window = window.downgrade();
|
||||
click.connect_pressed(move |_, n_press, _, _| {
|
||||
if n_press == 2
|
||||
&& let Some(window) = window.upgrade()
|
||||
{
|
||||
toggle_fullscreen(&window);
|
||||
}
|
||||
});
|
||||
video.add_controller(click);
|
||||
}
|
||||
|
||||
/// A button that enters and leaves `window`'s fullscreen, showing which one
|
||||
/// it will do.
|
||||
pub(crate) fn fullscreen_button(window: >k4::Window) -> gtk4::Button {
|
||||
let button = gtk4::Button::builder().valign(gtk4::Align::Center).build();
|
||||
let paint = |button: >k4::Button, fullscreen: bool| {
|
||||
if fullscreen {
|
||||
button.set_icon_name("view-restore-symbolic");
|
||||
button.set_tooltip_text(Some(&gettext("Leave Fullscreen (F)")));
|
||||
} else {
|
||||
button.set_icon_name("view-fullscreen-symbolic");
|
||||
button.set_tooltip_text(Some(&gettext("Fullscreen (F)")));
|
||||
}
|
||||
};
|
||||
paint(&button, window.is_fullscreen());
|
||||
let weak = button.downgrade();
|
||||
window.connect_fullscreened_notify(move |window| {
|
||||
if let Some(button) = weak.upgrade() {
|
||||
paint(&button, window.is_fullscreen());
|
||||
}
|
||||
});
|
||||
let target = window.downgrade();
|
||||
button.connect_clicked(move |_| {
|
||||
if let Some(window) = target.upgrade() {
|
||||
toggle_fullscreen(&window);
|
||||
}
|
||||
});
|
||||
button
|
||||
}
|
||||
|
||||
/// Play a video or a song from My files in a window of its own, streamed from
|
||||
/// the mount: the player reads the blocks it needs as it plays and seeks, so a
|
||||
/// long video starts in seconds instead of after a full download. A file the
|
||||
/// mount doesn't show, or the service is down, opens in the default
|
||||
/// application instead, after a download.
|
||||
pub(crate) fn open_media_player(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
let rel = entry_rel(ui, entry);
|
||||
let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config());
|
||||
let (ui, entry) = (ui.clone(), entry.clone());
|
||||
glib::spawn_future_local(async move {
|
||||
match present_within(mounted_path(&mountpoint, &rel)).await {
|
||||
Some(path) => build_media_player(&ui, &entry, &path),
|
||||
None => download_and_open(&ui, &entry),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn build_media_player(ui: &Rc<Ui>, entry: &DirEntry, path: &Path) {
|
||||
let window = adw::Window::builder()
|
||||
.title(&entry.name)
|
||||
.default_width(960)
|
||||
.default_height(600)
|
||||
.build();
|
||||
if let Some(parent) = ui_window(ui) {
|
||||
window.set_transient_for(Some(&parent));
|
||||
}
|
||||
window.add_css_class("media-player-window");
|
||||
let gtk_window: gtk4::Window = window.clone().upcast();
|
||||
|
||||
let video = gtk4::Video::builder()
|
||||
.autoplay(true)
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
wire_player(&video, >k_window);
|
||||
|
||||
// A song has no picture, which would leave a black window above the
|
||||
// controls: its cover stands in for it, the one its listing tile shows,
|
||||
// and a note while there is none.
|
||||
let note = file_thumbnail_widget(COVER_SIZE, 128);
|
||||
note.set_can_target(false);
|
||||
note.set_visible(false);
|
||||
note.add_css_class("media-player-note");
|
||||
if let Some(cover) = note
|
||||
.child()
|
||||
.and_then(|fallback| fallback.next_sibling())
|
||||
.and_downcast::<gtk4::Picture>()
|
||||
{
|
||||
// The whole cover, not a square cut out of it.
|
||||
cover.set_content_fit(gtk4::ContentFit::Contain);
|
||||
}
|
||||
bind_file_thumbnail(ui, ¬e, entry, true);
|
||||
let overlay = gtk4::Overlay::new();
|
||||
overlay.set_child(Some(&video));
|
||||
overlay.add_overlay(¬e);
|
||||
|
||||
let failed = adw::StatusPage::builder()
|
||||
.icon_name("dialog-warning-symbolic")
|
||||
.title(gettext("Couldn't play this file"))
|
||||
.build();
|
||||
let open_with = gtk4::Button::builder()
|
||||
.label(gettext("Open With…"))
|
||||
.halign(gtk4::Align::Center)
|
||||
.build();
|
||||
open_with.add_css_class("pill");
|
||||
open_with.add_css_class("suggested-action");
|
||||
failed.set_child(Some(&open_with));
|
||||
|
||||
let stack = gtk4::Stack::new();
|
||||
stack.add_named(&overlay, Some("player"));
|
||||
stack.add_named(&failed, Some("failed"));
|
||||
|
||||
let header = adw::HeaderBar::new();
|
||||
header.pack_end(&fullscreen_button(>k_window));
|
||||
let mut menu = ActionMenu::new();
|
||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||
menu.item(&gettext("Open With…"), move || {
|
||||
open_entry_with(&ui_c, &entry_c)
|
||||
});
|
||||
let video_c = video.clone();
|
||||
// Translators: menu item of the video player that plays the file again
|
||||
// and again.
|
||||
menu.toggle(&gettext("Repeat"), false, move |on| video_c.set_loop(on));
|
||||
header.pack_end(&menu.button());
|
||||
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
toolbar.add_top_bar(&header);
|
||||
toolbar.set_content(Some(&stack));
|
||||
// Fullscreen is the video alone, its own controls still at hand.
|
||||
window
|
||||
.bind_property("fullscreened", &toolbar, "reveal-top-bars")
|
||||
.invert_boolean()
|
||||
.sync_create()
|
||||
.build();
|
||||
window.set_content(Some(&toolbar));
|
||||
|
||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||
open_with.connect_clicked(move |_| open_entry_with(&ui_c, &entry_c));
|
||||
|
||||
let (stack_c, failed_c, note_c) = (stack.clone(), failed.clone(), note.clone());
|
||||
video.connect_media_stream_notify(move |video| {
|
||||
let Some(stream) = video.media_stream() else {
|
||||
return;
|
||||
};
|
||||
let (stack, failed) = (stack_c.clone(), failed_c.clone());
|
||||
stream.connect_error_notify(move |stream| {
|
||||
if let Some(error) = stream.error() {
|
||||
tracing::warn!("in-app player failed: {error}");
|
||||
failed.set_description(Some(&error.to_string()));
|
||||
stack.set_visible_child_name("failed");
|
||||
}
|
||||
});
|
||||
let note = note_c.clone();
|
||||
stream.connect_prepared_notify(move |stream| {
|
||||
note.set_visible(stream.is_prepared() && stream.has_audio() && !stream.has_video());
|
||||
});
|
||||
});
|
||||
video.set_filename(Some(path));
|
||||
|
||||
let keys = gtk4::EventControllerKey::new();
|
||||
let (video_k, window_k) = (video.clone(), gtk_window.downgrade());
|
||||
keys.connect_key_pressed(move |_, key, _code, state| {
|
||||
let Some(window) = window_k.upgrade() else {
|
||||
return glib::Propagation::Proceed;
|
||||
};
|
||||
let Some(name) = key.name() else {
|
||||
return glib::Propagation::Proceed;
|
||||
};
|
||||
// The open menu keeps its arrows and Enter.
|
||||
let in_menu = gtk4::prelude::GtkWindowExt::focus(&window)
|
||||
.is_some_and(|focus| focus.ancestor(gtk4::Popover::static_type()).is_some());
|
||||
if in_menu {
|
||||
return glib::Propagation::Proceed;
|
||||
}
|
||||
if player_key(&video_k, &name, state) {
|
||||
return glib::Propagation::Stop;
|
||||
}
|
||||
let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
|
||||
match name.as_str() {
|
||||
"space" => toggle_playing(&video_k),
|
||||
"Left" => seek_by(&video_k, -SEEK_SHORT),
|
||||
"Right" => seek_by(&video_k, SEEK_SHORT),
|
||||
"Up" => change_volume(&video_k, VOLUME_STEP),
|
||||
"Down" => change_volume(&video_k, -VOLUME_STEP),
|
||||
"f" | "F11" => toggle_fullscreen(&window),
|
||||
// Escape leaves fullscreen before it closes anything.
|
||||
"Escape" if window.is_fullscreen() => window.unfullscreen(),
|
||||
"Escape" | "q" => window.close(),
|
||||
"w" if ctrl => window.close(),
|
||||
_ => return glib::Propagation::Proceed,
|
||||
}
|
||||
glib::Propagation::Stop
|
||||
});
|
||||
// Capture, so the arrows and Space reach the player rather than moving the
|
||||
// focus between its controls.
|
||||
keys.set_propagation_phase(gtk4::PropagationPhase::Capture);
|
||||
window.add_controller(keys);
|
||||
|
||||
// Stop the sound with the window, not when the window is finally freed.
|
||||
let video_c = video.clone();
|
||||
window.connect_close_request(move |_| {
|
||||
video_c.set_media_stream(gtk4::MediaStream::NONE);
|
||||
glib::Propagation::Proceed
|
||||
});
|
||||
window.present();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SEC: i64 = 1_000_000;
|
||||
|
||||
#[test]
|
||||
fn a_jump_stays_inside_the_video() {
|
||||
assert_eq!(seek_target(30 * SEC, 60 * SEC, 10), 40 * SEC);
|
||||
assert_eq!(seek_target(30 * SEC, 60 * SEC, -10), 20 * SEC);
|
||||
assert_eq!(seek_target(5 * SEC, 60 * SEC, -10), 0);
|
||||
assert_eq!(seek_target(55 * SEC, 60 * SEC, 10), 60 * SEC);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_jump_forward_in_a_stream_of_unknown_length_is_not_held_back() {
|
||||
assert_eq!(seek_target(30 * SEC, 0, 10), 40 * SEC);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use pdfs_core::control::{FileThumbRequest, is_thumbnail_media_name, is_video_fil
|
||||
const THUMB_BATCH: usize = 32;
|
||||
const THUMB_CACHE_MAX: usize = 256;
|
||||
const THUMB_MISSING_MAX: usize = 2048;
|
||||
const THUMB_BLANK_MAX: usize = 1024;
|
||||
const THUMB_DEBOUNCE: Duration = Duration::from_millis(40);
|
||||
const THUMB_RETRY: Duration = Duration::from_secs(2);
|
||||
|
||||
@@ -20,6 +21,9 @@ pub(crate) struct FileThumbnailState {
|
||||
missing: RefCell<HashSet<String>>,
|
||||
missing_order: RefCell<VecDeque<String>>,
|
||||
wanted: RefCell<HashMap<String, Vec<gtk4::Picture>>>,
|
||||
/// Tiles on screen still showing their icon, by key, kept across a
|
||||
/// cancelled request so [`refresh_file_thumbnails`] can ask for them again.
|
||||
blank: RefCell<HashMap<String, BlankTile>>,
|
||||
queue: RefCell<VecDeque<FileThumbRequest>>,
|
||||
inflight: Cell<bool>,
|
||||
source: RefCell<Option<glib::SourceId>>,
|
||||
@@ -27,6 +31,18 @@ pub(crate) struct FileThumbnailState {
|
||||
generation_loading: Cell<bool>,
|
||||
}
|
||||
|
||||
/// What a tile left blank needs to be asked for again.
|
||||
struct BlankTile {
|
||||
request: FileThumbRequest,
|
||||
pictures: Vec<glib::WeakRef<gtk4::Picture>>,
|
||||
}
|
||||
|
||||
/// Whether a picture registered as blank under `key` still is: bound to that
|
||||
/// key, not painted, and in a window rather than in a list's spare pool.
|
||||
fn tile_still_blank(key: &str, bound: &str, painted: bool, attached: bool) -> bool {
|
||||
bound == key && !painted && attached
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum ReplyDisposition {
|
||||
Ignore,
|
||||
@@ -55,6 +71,7 @@ impl FileThumbnailState {
|
||||
missing: RefCell::new(HashSet::new()),
|
||||
missing_order: RefCell::new(VecDeque::new()),
|
||||
wanted: RefCell::new(HashMap::new()),
|
||||
blank: RefCell::new(HashMap::new()),
|
||||
queue: RefCell::new(VecDeque::new()),
|
||||
inflight: Cell::new(false),
|
||||
source: RefCell::new(None),
|
||||
@@ -88,6 +105,85 @@ impl FileThumbnailState {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn remember_blank(&self, key: &str, request: &FileThumbRequest, picture: >k4::Picture) {
|
||||
let mut blank = self.blank.borrow_mut();
|
||||
if blank.len() >= THUMB_BLANK_MAX {
|
||||
prune_blank(&mut blank);
|
||||
}
|
||||
let tile = blank.entry(key.to_string()).or_insert_with(|| BlankTile {
|
||||
request: request.clone(),
|
||||
pictures: Vec::new(),
|
||||
});
|
||||
if !tile
|
||||
.pictures
|
||||
.iter()
|
||||
.any(|weak| weak.upgrade().as_ref() == Some(picture))
|
||||
{
|
||||
tile.pictures.push(picture.downgrade());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop the tiles that were painted, rebound to another file, or freed.
|
||||
fn prune_blank(blank: &mut HashMap<String, BlankTile>) {
|
||||
blank.retain(|key, tile| {
|
||||
tile.pictures.retain(|weak| {
|
||||
weak.upgrade().is_some_and(|picture| {
|
||||
tile_still_blank(
|
||||
key,
|
||||
&picture.widget_name(),
|
||||
picture.is_visible(),
|
||||
picture.root().is_some(),
|
||||
)
|
||||
})
|
||||
});
|
||||
!tile.pictures.is_empty()
|
||||
});
|
||||
}
|
||||
|
||||
/// Ask again for every tile on screen still showing its icon. Sent when the
|
||||
/// daemon says thumbnails landed, so a folder fills in while a build runs in
|
||||
/// it, or after its own requests were cancelled, without being opened again.
|
||||
pub(crate) fn refresh_file_thumbnails(ui: &Rc<Ui>) {
|
||||
let tiles: Vec<(String, FileThumbRequest, Vec<gtk4::Picture>)> = {
|
||||
let mut blank = ui.file_thumbs.blank.borrow_mut();
|
||||
prune_blank(&mut blank);
|
||||
blank
|
||||
.iter()
|
||||
.map(|(key, tile)| {
|
||||
let pictures = tile.pictures.iter().filter_map(|w| w.upgrade()).collect();
|
||||
(key.clone(), tile.request.clone(), pictures)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
if tiles.is_empty() {
|
||||
return;
|
||||
}
|
||||
for (key, request, pictures) in tiles {
|
||||
let cached = ui.file_thumbs.textures.borrow().get(&key).cloned();
|
||||
if let Some(texture) = cached {
|
||||
for picture in &pictures {
|
||||
paint(picture, &key, &texture);
|
||||
}
|
||||
ui.file_thumbs.blank.borrow_mut().remove(&key);
|
||||
continue;
|
||||
}
|
||||
// A miss from before the thumbnail landed is no verdict any more.
|
||||
ui.file_thumbs.missing.borrow_mut().remove(&key);
|
||||
let mut wanted = ui.file_thumbs.wanted.borrow_mut();
|
||||
let waiters = wanted.entry(key).or_default();
|
||||
for picture in pictures {
|
||||
if !waiters.contains(&picture) {
|
||||
waiters.push(picture);
|
||||
}
|
||||
}
|
||||
drop(wanted);
|
||||
let mut queue = ui.file_thumbs.queue.borrow_mut();
|
||||
queue.retain(|item| item.uid != request.uid);
|
||||
queue.push_back(request);
|
||||
}
|
||||
schedule_file_thumbs(ui);
|
||||
}
|
||||
|
||||
/// Abandon every opportunistic ordinary-file thumbnail associated with the
|
||||
@@ -230,6 +326,12 @@ pub(crate) fn bind_file_thumbnail(
|
||||
paint(&picture, &key, texture);
|
||||
return;
|
||||
}
|
||||
let request = FileThumbRequest {
|
||||
uid: entry.uid.clone(),
|
||||
modified: entry.modified,
|
||||
name: entry.name.clone(),
|
||||
};
|
||||
ui.file_thumbs.remember_blank(&key, &request, &picture);
|
||||
if ui.file_thumbs.missing.borrow().contains(&key) {
|
||||
return;
|
||||
}
|
||||
@@ -242,11 +344,7 @@ pub(crate) fn bind_file_thumbnail(
|
||||
.push(picture);
|
||||
let mut queue = ui.file_thumbs.queue.borrow_mut();
|
||||
queue.retain(|item| item.uid != entry.uid);
|
||||
queue.push_back(FileThumbRequest {
|
||||
uid: entry.uid.clone(),
|
||||
modified: entry.modified,
|
||||
name: entry.name.clone(),
|
||||
});
|
||||
queue.push_back(request);
|
||||
drop(queue);
|
||||
schedule_file_thumbs(ui);
|
||||
}
|
||||
@@ -387,6 +485,7 @@ fn flush_file_thumbs(ui: &Rc<Ui>) {
|
||||
match gtk4::gdk::Texture::from_filename(&path) {
|
||||
Ok(texture) => {
|
||||
ui.file_thumbs.store_texture(&key, texture.clone());
|
||||
ui.file_thumbs.blank.borrow_mut().remove(&key);
|
||||
if let Some(pictures) =
|
||||
ui.file_thumbs.wanted.borrow_mut().remove(&key)
|
||||
{
|
||||
@@ -447,7 +546,16 @@ fn retry_file_thumbs(ui: &Rc<Ui>, items: Vec<FileThumbRequest>) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{ReplyDisposition, reply_disposition};
|
||||
use super::{ReplyDisposition, reply_disposition, tile_still_blank};
|
||||
|
||||
#[test]
|
||||
fn only_a_tile_still_showing_its_icon_is_asked_for_again() {
|
||||
assert!(tile_still_blank("a:1", "a:1", false, true));
|
||||
assert!(!tile_still_blank("a:1", "a:1", true, true), "painted");
|
||||
assert!(!tile_still_blank("a:1", "b:1", false, true), "rebound");
|
||||
assert!(!tile_still_blank("a:1", "a:2", false, true), "file changed");
|
||||
assert!(!tile_still_blank("a:1", "a:1", false, false), "spare row");
|
||||
}
|
||||
|
||||
fn is_image_name(name: &str) -> bool {
|
||||
pdfs_core::control::is_thumbnail_image_name(name)
|
||||
|
||||
@@ -24,6 +24,7 @@ const STOCK: &[&str] = &[
|
||||
// GTK's built-in icons
|
||||
"info-outline-symbolic",
|
||||
// Adwaita
|
||||
"audio-x-generic-symbolic",
|
||||
"checkbox-checked-symbolic",
|
||||
"checkbox-symbolic",
|
||||
"computer-symbolic",
|
||||
@@ -68,12 +69,14 @@ const STOCK: &[&str] = &[
|
||||
"text-x-generic-symbolic",
|
||||
"user-trash-symbolic",
|
||||
"video-x-generic-symbolic",
|
||||
"view-fullscreen-symbolic",
|
||||
"view-grid-symbolic",
|
||||
"view-list-symbolic",
|
||||
"view-more-symbolic",
|
||||
"view-paged-symbolic",
|
||||
"view-pin-symbolic",
|
||||
"view-refresh-symbolic",
|
||||
"view-restore-symbolic",
|
||||
"window-close-symbolic",
|
||||
"zoom-fit-best-symbolic",
|
||||
"zoom-in-symbolic",
|
||||
|
||||
@@ -109,6 +109,7 @@ a live account since.
|
||||
|
||||
| Entry | Title |
|
||||
|---|---|
|
||||
| [B284](#b284--a-keyring-entry-reported-missing-discards-refreshed-session-tokens) | A keyring entry reported missing discards refreshed session tokens |
|
||||
| [B283](#b283--videos-have-no-thumbnail-outside-photos-and-a-ts-recording-looks-like-source-code) | Videos have no thumbnail outside Photos, and a `.ts` recording looks like source code |
|
||||
| [B282](#b282--a-file-kept-offline-is-downloaded-again-after-a-change-made-elsewhere) | A file kept offline is downloaded again after a change made elsewhere |
|
||||
| [B281](#b281--a-file-in-a-synced-folder-cannot-be-kept-offline) | A file in a synced folder cannot be kept offline |
|
||||
@@ -386,6 +387,34 @@ a live account since.
|
||||
|
||||
Newest first.
|
||||
|
||||
### B284 — A keyring entry reported missing discards refreshed session tokens
|
||||
|
||||
**Status:** Fixed (unverified)
|
||||
**Found:** 2026-11-14, investigating [issue #78](https://github.com/narrrl/proton-drive-linux/issues/78)
|
||||
after the reporter's oo7 0.7.0 retest ruled out the original persistence fix as a sufficient
|
||||
explanation. See [the investigation and isolated reproducer](ISSUE-78.md).
|
||||
**Where:** `crates/pdfs-core/src/auth.rs`, `TokenSink::flush`
|
||||
|
||||
**Repro:** Refresh a session while the keyring lookup reports `NoEntry` for its existing blob.
|
||||
oo7 0.7.0 does this for a locked collection (upstream #614), reproduced with fake tokens on a
|
||||
private D-Bus connection. pdfs dropped the pending rotation and returned success, leaving the
|
||||
spent refresh token in the saved blob without triggering B61's retry or kernel recovery paths.
|
||||
The running session can still work, but restarting it exposes the stale saved credentials.
|
||||
|
||||
**Fix:** Propagate the missing-session error so the rotation stays pending and is retried.
|
||||
Persistence still requires the matching existing blob; a retry never recreates a deleted
|
||||
session or overwrites a new login. This does not make a locked keyring writable, and pending
|
||||
tokens still need to reach it before power-off to survive a cold boot.
|
||||
|
||||
**Verified:** The two regression tests
|
||||
`auth::tests::a_temporarily_missing_session_does_not_discard_a_rotation` and
|
||||
`auth::tests::retrying_a_missing_session_never_recreates_it_or_overwrites_a_new_login`
|
||||
failed before the fix and pass afterward. The oo7 fixture confirms its locked lookup behavior
|
||||
and repeated binary `SetSecret` persistence. The reporter's complete Fedora cold-boot sequence
|
||||
has not been reproduced; issue #78's precise trigger remains unconfirmed.
|
||||
|
||||
---
|
||||
|
||||
### B283 — Videos have no thumbnail outside Photos, and a `.ts` recording looks like source code
|
||||
|
||||
**Status:** Fixed, not verified live
|
||||
|
||||
@@ -88,6 +88,30 @@ Existing folders stay device backups. Older builds cannot open the upgraded data
|
||||
slideshow.
|
||||
- The line under the date names the town the photo was taken in, when it is known.
|
||||
|
||||
- **Drag photos out of Photos.** Select photos, then drag one of them into a file manager, a chat
|
||||
or any app that takes files. The photos download when dropped and arrive under their own
|
||||
names. A drag that starts on a photo that is not selected still selects, as before.
|
||||
|
||||
- **Upload progress in Photos.** Uploading several photos at once shows "Uploading 3 of 12
|
||||
items…" with a progress bar above the timeline, instead of a single toast at the start.
|
||||
|
||||
- **Videos and music play in the app.** Opening a video or a song in My files, by a double-click
|
||||
or **Open**, plays it in a player window, streamed from the mount like before. **Open With…**
|
||||
still hands it to another player, and the separate **Play** item is gone. When the file can't
|
||||
be played (a codec GStreamer lacks), the window says so and offers **Open With…**. Both this
|
||||
player and the one in Photos gained a fullscreen button, fullscreen on a double-click,
|
||||
**Repeat** in the ⋮ menu, `K` to play and pause, `J` and `L` (or Shift+←/→) to jump
|
||||
10 seconds, and `M` to mute. The volume carries over from one video to the next, and the
|
||||
thumbnail strip in Photos stays off a video, where it covered the seek bar.
|
||||
|
||||
- **Album covers for songs.** A song in My files, in search and in Shared shows its cover as its
|
||||
thumbnail, and the player shows it above the controls instead of a music note. The cover is the
|
||||
picture in the song's tags (MP3, FLAC, Ogg, Opus, M4A, WAV, AIFF, WavPack, Monkey's Audio), or
|
||||
else a `cover`, `folder`, `front` or `album` image (`.jpg`, `.png`, `.webp`) in the same folder.
|
||||
Only the song's tags are downloaded for it, not the whole song. Songs uploaded from this computer
|
||||
carry their cover as their thumbnail, so they show it at once on other devices too. A cover
|
||||
image added to a folder later is picked up once the song changes or the service restarts.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`proton-sdk` and `proton-drive-rs` 0.11.0.** Timestamps are converted where they enter
|
||||
@@ -96,6 +120,15 @@ Existing folders stay device backups. Older builds cannot open the upgraded data
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Photos zoomed all the way out shows the whole timeline.** The first 200 photos fit on one
|
||||
screen there, and with nothing to scroll no more were loaded. More now load whenever the photos
|
||||
don't fill the window, also after a zoom or a resize.
|
||||
|
||||
- **Thumbnails appear in the folder you are looking at as soon as they are made.** During
|
||||
**Build thumbnails**, the folder on screen kept its file icons until you left it and opened it
|
||||
again. Its tiles now fill in while the build runs, and the same goes for any tile whose
|
||||
thumbnail is made while it is on screen.
|
||||
|
||||
- **My files comes back after the service was killed for hanging** (B279). A killed service left
|
||||
its folders behind as dead mounts, and every restart then failed with `File exists` and retried
|
||||
forever without opening anything, until the mounts were cleared by hand. The service now clears
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# Issue #78: session loss on Fedora 45
|
||||
|
||||
Investigation on 2026-11-14 of [issue #78](https://github.com/narrrl/proton-drive-linux/issues/78),
|
||||
using its report and comments through `gh`, pdfs 3.7.0/current source, and the oo7 0.7.0 tag.
|
||||
|
||||
## What is established
|
||||
|
||||
The reporter still saw the problem with oo7 0.7.0 on November 7. The original
|
||||
[SetSecret persistence fix](https://github.com/linux-credentials/oo7/commit/7fb11dd111c4be8fec01996e3caa31c44fa962b3)
|
||||
is present in that release. In an isolated test, three successive binary `SetSecret` updates
|
||||
survived reopening the keyring file. Repeating the original persistence explanation is therefore
|
||||
insufficient.
|
||||
|
||||
A different, reproducible failure exists at the boundary between oo7 and pdfs:
|
||||
|
||||
1. oo7 0.7.0's `Collection::search_inner_items` returns an empty list for a locked collection.
|
||||
`SearchItems` consequently returns **both** the unlocked and locked lists empty even when the
|
||||
matching item is on disk. The isolated test reproduces this with pdfs's exact lookup attributes.
|
||||
This is upstream [oo7 #614](https://github.com/linux-credentials/oo7/issues/614).
|
||||
2. `dbus-secret-service-keyring-store` 1.0.1 maps an empty search to `keyring_core::Error::NoEntry`;
|
||||
pdfs maps that to `Error::NotLoggedIn`. The client cannot offer an unlock when no locked item
|
||||
is reported.
|
||||
3. In pdfs 3.7.0 and the code before this change, `TokenSink::flush` treated `NotLoggedIn` as a
|
||||
logout, cleared the pending rotation and returned success. It skipped the error path that
|
||||
retains the new tokens in the kernel keyring and retries their persistence. The code was
|
||||
unchanged between v3.7.0 and this investigation.
|
||||
4. If a token refresh encounters that reply, the saved blob retains a spent refresh token. The
|
||||
running process can keep working with the new tokens in memory; a restart then exposes the
|
||||
stale saved credentials. A regression test failed before the fix and passes afterward.
|
||||
|
||||
The same unsafe assumption also applies to transient empty lookups such as the race reported in
|
||||
[oo7 #652](https://github.com/linux-credentials/oo7/issues/652), fixed in 0.7.1. That issue's
|
||||
replacement race was not independently reproduced here; pdfs normally updates existing items
|
||||
with `SetSecret`, so it must not be assumed to encounter the same-item `CreateItem` race.
|
||||
|
||||
## Change and limits
|
||||
|
||||
`TokenSink::flush` now propagates a missing-session error instead of discarding the rotation.
|
||||
The existing retry, kernel-keyring recovery and unsaved-token status paths can then run. Once
|
||||
the collection is unlocked and readable, the pending rotation is saved. A genuinely deleted
|
||||
session is never recreated: flushing still requires a readable blob with the matching session
|
||||
ID and expected refresh token. A new login causes the old rotation to be discarded as before.
|
||||
|
||||
This is a confirmed pdfs defect (B284), **not a confirmed complete explanation of the reporter's
|
||||
cold-boot failure**. The issue has no oo7 logs or record showing that its collection was locked
|
||||
when a token rotated. A locked keyring only at startup can hide a login, but does not by itself
|
||||
explain `InvalidRefreshToken`. The token-loss path requires a successful refresh while the saved
|
||||
entry is unreadable.
|
||||
|
||||
The fix does not make a locked keyring writable. If it stays unreadable until power-off, tokens
|
||||
retained in process/kernel memory still cannot survive a cold boot. No new disk credential store
|
||||
or credential format is introduced.
|
||||
|
||||
Other upstream Fedora-upgrade issues were checked: [oo7 #651](https://github.com/linux-credentials/oo7/issues/651)
|
||||
creates duplicate placeholder/migrated collections and refuses writes; [oo7 #662](https://github.com/linux-credentials/oo7/issues/662)
|
||||
can resolve the default alias to a locked collection. Both have fixes in
|
||||
[0.7.1](https://github.com/linux-credentials/oo7/releases/tag/0.7.1), released November 10,
|
||||
after the reporter's November 7 retest. Neither can be assigned as this report's cause without
|
||||
matching runtime evidence.
|
||||
|
||||
## Reproduce without a Proton account or desktop keyring
|
||||
|
||||
The oo7 fixture uses fake tokens, a temporary disk keyring and a private peer-to-peer D-Bus
|
||||
connection. It asserts the observed 0.7.0 behavior, including the incorrect empty locked lookup;
|
||||
passing means that behavior was reproduced. It does not simulate Proton refresh or a full boot.
|
||||
|
||||
From the pdfs repository, choose an unused temporary checkout path:
|
||||
|
||||
```sh
|
||||
git clone --depth 1 --branch 0.7.0 https://github.com/linux-credentials/oo7.git /tmp/pdfs-oo7-repro
|
||||
git -C /tmp/pdfs-oo7-repro apply "$PWD/docs/repro/oo7-0.7.0-issue78.patch"
|
||||
cargo test --manifest-path /tmp/pdfs-oo7-repro/Cargo.toml \
|
||||
-p oo7-daemon --lib --features test-util issue78 -- --nocapture
|
||||
```
|
||||
|
||||
Observed output:
|
||||
|
||||
```text
|
||||
oo7 0.7.0: locked session is on disk but SearchItems returns (0, 0)
|
||||
oo7 0.7.0: all three binary SetSecret updates survived reopening the disk keyring
|
||||
test item::tests::issue78_locked_lookup_hides_saved_tokens ... ok
|
||||
```
|
||||
|
||||
The pdfs side is covered by `cargo test -p pdfs-core auth::tests`, specifically
|
||||
`a_temporarily_missing_session_does_not_discard_a_rotation` and
|
||||
`retrying_a_missing_session_never_recreates_it_or_overwrites_a_new_login`.
|
||||
|
||||
Remaining live validation: reproduce on a Fedora 45 GNOME session across logout/shutdown,
|
||||
correlating oo7 lock/write errors with pdfs refresh and retry logs, and checking the running
|
||||
daemon's package version rather than just the CLI version. The investigation did not use a real
|
||||
Proton account, alter a desktop keyring, or post to either GitHub issue.
|
||||
@@ -0,0 +1,53 @@
|
||||
diff --git a/server/src/item/tests.rs b/server/src/item/tests.rs
|
||||
index 99c25dc..2ba705d 100644
|
||||
--- a/server/src/item/tests.rs
|
||||
+++ b/server/src/item/tests.rs
|
||||
@@ -5,6 +5,48 @@ use tokio_stream::StreamExt;
|
||||
|
||||
use crate::{service::PrompterType, tests::TestServiceSetup};
|
||||
|
||||
+// Investigation for proton-drive-linux #78. Fake tokens and a private p2p bus;
|
||||
+// no desktop keyring or Proton account is used.
|
||||
+#[tokio::test]
|
||||
+async fn issue78_locked_lookup_hides_saved_tokens() -> Result<(), Box<dyn std::error::Error>> {
|
||||
+ let setup =
|
||||
+ TestServiceSetup::with_login_keyring(oo7::Secret::from("test-password-long-enough"))
|
||||
+ .await?;
|
||||
+ let collection = setup.default_collection().await?;
|
||||
+ let attributes = [("service", "proton-drive-linux"), ("username", "session")];
|
||||
+ let secret = setup.create_dbus_secret(oo7::Secret::blob(b"fake-refresh-r1"))?;
|
||||
+ collection
|
||||
+ .create_item("pdfs fake session", &attributes, &secret, false, None)
|
||||
+ .await?;
|
||||
+
|
||||
+ let (unlocked, locked) = setup.service_api.search_items(&attributes).await?;
|
||||
+ assert_eq!((unlocked.len(), locked.len()), (1, 0));
|
||||
+ setup.lock_collection(collection).await?;
|
||||
+ assert!(collection.is_locked().await?);
|
||||
+
|
||||
+ let (unlocked, locked) = setup.service_api.search_items(&attributes).await?;
|
||||
+ // This is the problematic reply, not the Secret Service behavior expected
|
||||
+ // by keyring-store: the saved item is neither unlocked nor reported locked.
|
||||
+ assert_eq!((unlocked.len(), locked.len()), (0, 0));
|
||||
+ assert_eq!(setup.login_keyring().await?.items().await?.len(), 1);
|
||||
+ eprintln!("oo7 0.7.0: locked session is on disk but SearchItems returns (0, 0)");
|
||||
+
|
||||
+ setup.unlock_collection(collection).await?;
|
||||
+ let (unlocked, locked) = setup.service_api.search_items(&attributes).await?;
|
||||
+ assert_eq!((unlocked.len(), locked.len()), (1, 0));
|
||||
+
|
||||
+ // Exercise the binary SetSecret path used by pdfs's keyring-store. Reopen
|
||||
+ // the disk keyring after each update, rather than trusting a memory read.
|
||||
+ for refresh in [b"fake-refresh-r2", b"fake-refresh-r3", b"fake-refresh-r4"] {
|
||||
+ let secret = setup.create_dbus_secret(oo7::Secret::blob(refresh))?;
|
||||
+ unlocked[0].set_secret(&secret).await?;
|
||||
+ let items = setup.login_keyring().await?.items().await?;
|
||||
+ assert_eq!(items[0].secret().as_bytes(), refresh);
|
||||
+ }
|
||||
+ eprintln!("oo7 0.7.0: all three binary SetSecret updates survived reopening the disk keyring");
|
||||
+ Ok(())
|
||||
+}
|
||||
+
|
||||
#[tokio::test]
|
||||
async fn label_property() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let setup = TestServiceSetup::plain_session(true).await?;
|
||||
@@ -24,6 +24,7 @@ crates/pdfs-gui/src/app/widgets/compat.rs
|
||||
crates/pdfs-gui/src/app/widgets/details.rs
|
||||
crates/pdfs-gui/src/app/widgets/file_list.rs
|
||||
crates/pdfs-gui/src/app/widgets/menu.rs
|
||||
crates/pdfs-gui/src/app/widgets/player.rs
|
||||
crates/pdfs-gui/src/app/widgets/share_dialog.rs
|
||||
crates/pdfs-gui/src/app/widgets/thumbnails.rs
|
||||
crates/pdfs-gui/src/app/widgets/versions_dialog.rs
|
||||
|
||||
+445
-406
File diff suppressed because it is too large.
Load diff
+435
-406
File diff suppressed because it is too large.
Load diff
+445
-406
File diff suppressed because it is too large.
Load diff
@@ -91,7 +91,7 @@ SECTIONS = [
|
||||
]
|
||||
|
||||
# Too long or too internal for the wiki; links to them go to GitHub.
|
||||
EXCLUDED = {"BUGS.md", "CHANGELOG.md", "SDK-0.11.0.md"}
|
||||
EXCLUDED = {"BUGS.md", "CHANGELOG.md", "ISSUE-78.md", "SDK-0.11.0.md"}
|
||||
|
||||
# Files outside docs/ whose links are checked but not published.
|
||||
CHECKED = ["README.md", "CONTRIBUTING.md", "SECURITY.md"]
|
||||
|
||||
Reference in new issue
Block a user